Compare commits

...

2 Commits

Author SHA1 Message Date
Tomas Dvorak 2039669e2c feat(ui): add analytics dashboard and enhance frontend components
CI / Frontend (push) Successful in 10m8s
CI / Go - apps/auth-service (push) Failing after 3s
CI / Go - apps/backend (push) Successful in 10m9s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
- Implement new analytics section in the dashboard with KPI cards and charts
- Add video player scrubber and playback controls
- Improve FloatingDock layout and responsiveness
- Enhance HomeRoute with demo mode redirection and improved trust section UI
- Update dashboard routing to support the new analytics tab
2026-05-18 18:27:54 +02:00
Tomas Dvorak da5ba13eab feat(ui): implement comprehensive dashboard and enhance frontend experience
This commit introduces a major overhaul of the user interface, transitioning from a basic structure to a feature-rich dashboard system. Key improvements include:

- **Dashboard Implementation**: Added a complete dashboard routing system with dedicated pages for Overview, Bookings, Customers, Zones, Billing, and Settings.
- **New UI Components**: Introduced a variety of high-quality components including `AnimatedList`, `FloatingDock`, `HoverFeatureCards`, `VideoPlayer` (with ambient glow effect), `PinnedList`, and `DashboardMockup`.
- **Enhanced Dashboard Features**:
    - Integrated real-time KPI cards and activity timelines.
    - Implemented a multi-view calendar system.
    - Added customer and booking management interfaces with filtering and search capabilities.
    - Added a zone/location management view with map integration.
- **Branding & Visuals**: Updated the application with new SVG logos (horizontal and vertical variants) and implemented dark/light mode optimized branding.
- **Internationalization**: Expanded i18n support with comprehensive Czech and English translations for the new dashboard and integration modules.
- **Integration Tools**: Added a new `IntegrationModal` allowing users to easily embed Bookra widgets via HTML, React, SolidJS, or PHP.
- **Backend Support**: Updated the booking service to provide comprehensive dashboard summary data, including historical booking records for charts.
2026-05-18 14:31:20 +02:00
42 changed files with 9040 additions and 191 deletions
+20
View File
@@ -336,6 +336,25 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
upcoming = upcoming[:5]
}
// Fetch all bookings for the last 30 days + next 30 days for chart and bookings page
allFrom := now.AddDate(0, 0, -30)
allTo := now.AddDate(0, 0, 30)
allRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, allFrom, allTo)
if err != nil {
return domain.DashboardSummary{}, err
}
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
for _, booking := range allRecords {
allBookings = append(allBookings, domain.UpcomingBooking{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
}
return domain.DashboardSummary{
TenantName: membership.Tenant.Name,
TenantSlug: membership.Tenant.Slug,
@@ -350,6 +369,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
},
UpcomingBookings: upcoming,
AllBookings: allBookings,
WidgetSnippets: widgetSnippets(membership.Tenant),
Tracking: trackingStatus(s.repo, ctx, membership.Tenant),
}, nil
+1
View File
@@ -47,6 +47,7 @@ type DashboardSummary struct {
SetupCompletion int `json:"setupCompletion"`
KPIs []DashboardKPI `json:"kpis"`
UpcomingBookings []UpcomingBooking `json:"upcomingBookings"`
AllBookings []UpcomingBooking `json:"allBookings"`
WidgetSnippets []WidgetSnippet `json:"widgetSnippets"`
Tracking TrackingStatus `json:"tracking"`
}
+11 -3
View File
@@ -1,13 +1,21 @@
<!doctype html>
<html lang="en">
<html lang="cs">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Bookra - Calm booking software for salons, clinics, and local service businesses. Simple setup, reliable scheduling, clear reminders." />
<meta name="description" content="Bookra — jednoduchý rezervační software pro salony, kliniky a lokální služby. Rychlé nastavení, spolehlivé plánování, automatická připomenutí." />
<meta name="theme-color" content="#f6f4ee" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1a1816" media="(prefers-color-scheme: dark)" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Bookra — Calm Booking Software</title>
<link rel="canonical" href="https://bookra.eu" />
<title>Bookra — Jednoduchý rezervační software</title>
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://bookra.eu" />
<meta property="og:title" content="Bookra — Jednoduchý rezervační software" />
<meta property="og:description" content="Spravujte rezervace, zákazníky a tým na jednom místě. Bez zbytečné složitosti." />
<meta property="og:locale" content="cs_CZ" />
<!-- Preconnect to Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 100 KiB

@@ -0,0 +1,46 @@
import { For, type JSX, createEffect } from "solid-js";
export type AnimationType = "scale" | "slide" | "fade" | "bounce";
export interface AnimatedListProps<T> {
items: T[];
renderItem: (item: T, index: number) => JSX.Element;
maxVisible?: number;
gap?: number;
animation?: AnimationType;
className?: string;
}
function animationClass(type: AnimationType) {
switch (type) {
case "slide": return "animate-list-slide";
case "fade": return "animate-list-fade";
case "bounce": return "animate-list-bounce";
case "scale":
default: return "animate-list-scale";
}
}
export function AnimatedList<T extends { id: string | number }>(props: AnimatedListProps<T>) {
const maxVisible = () => props.maxVisible ?? 8;
const gap = () => props.gap ?? 12;
const animType = () => props.animation ?? "scale";
const visible = () => props.items.slice(0, maxVisible());
return (
<div class={`flex flex-col ${props.className ?? ""}`} style={{ gap: `${gap()}px` }}>
<For each={visible()}>
{(item, index) => (
<div
class={`${animationClass(animType())} will-change-transform`}
style={{
"animation-delay": `${index() * 40}ms`,
}}
>
{props.renderItem(item, index())}
</div>
)}
</For>
</div>
);
}
@@ -0,0 +1,105 @@
import { createSignal, onCleanup, onMount } from "solid-js";
export function DashboardMockup() {
const [activeBar, setActiveBar] = createSignal<number | null>(null);
const [pulseKpi, setPulseKpi] = createSignal<number | null>(null);
let timer: ReturnType<typeof setInterval>;
onMount(() => {
let idx = 0;
timer = setInterval(() => {
setActiveBar(idx % 7);
setPulseKpi(idx % 3);
idx++;
}, 1200);
});
onCleanup(() => clearInterval(timer));
const kpis = [
{ label: "Rezervace", value: "48", trend: "+12%" },
{ label: "Obrat", value: "24K", trend: "+8%" },
{ label: "Klienti", value: "156", trend: "+5%" },
];
const bookings = [
{ time: "09:00", name: "Martina N.", service: "Masáž" },
{ time: "11:30", name: "David S.", service: "Fyzio" },
{ time: "14:00", name: "Jana K.", service: "Manikúra" },
];
const barData = [35, 55, 40, 70, 50, 85, 60];
const barLabels = ["Po", "Út", "St", "Čt", "Pá", "So", "Ne"];
return (
<div class="w-full h-full flex flex-col gap-2 select-none">
{/* Mini header */}
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<div class="w-4 h-4 rounded bg-accent/20" />
<span class="text-[10px] font-medium text-ink-muted">Dashboard</span>
</div>
<div class="flex gap-1">
<div class="w-1.5 h-1.5 rounded-full bg-success" />
<span class="text-[9px] text-ink-subtle">Online</span>
</div>
</div>
{/* KPI row */}
<div class="grid grid-cols-3 gap-2">
{kpis.map((kpi, i) => (
<div
class={`rounded-lg border border-border/60 p-2 bg-canvas-subtle/40 transition-all duration-500 ${
pulseKpi() === i ? "ring-1 ring-accent/30" : ""
}`}
>
<p class="text-[9px] text-ink-subtle mb-0.5">{kpi.label}</p>
<p class="text-sm font-display font-bold text-ink leading-none">{kpi.value}</p>
<p class="text-[9px] text-success mt-0.5">{kpi.trend}</p>
</div>
))}
</div>
{/* Mini bar chart */}
<div class="flex-1 min-h-0 rounded-lg border border-border/60 bg-canvas-subtle/30 p-2 flex flex-col">
<p class="text-[9px] text-ink-subtle mb-1.5">Trend rezervací</p>
<div class="flex-1 flex items-end gap-1">
{barData.map((h, i) => (
<div class="flex-1 flex flex-col items-center gap-1 group">
<div
class={`w-full rounded-t transition-all duration-700 ease-out ${
activeBar() === i ? "bg-accent" : "bg-accent/25"
}`}
style={{ height: `${h}%` }}
/>
<span class="text-[8px] text-ink-subtle">{barLabels[i]}</span>
</div>
))}
</div>
</div>
{/* Mini booking list */}
<div class="rounded-lg border border-border/60 bg-canvas-subtle/30 p-2">
<p class="text-[9px] text-ink-subtle mb-1.5">Dnešní rezervace</p>
<div class="space-y-1">
{bookings.map((b, i) => (
<div
class={`flex items-center gap-2 rounded px-1.5 py-1 transition-colors duration-300 ${
activeBar() === i ? "bg-accent/10" : ""
}`}
>
<span class="text-[9px] font-medium text-accent w-7 shrink-0">{b.time}</span>
<div class="w-4 h-4 rounded-full bg-accent-subtle flex items-center justify-center text-[7px] font-bold text-accent shrink-0">
{b.name.split(" ").map((n) => n[0]).join("")}
</div>
<div class="min-w-0">
<p class="text-[10px] font-medium text-ink truncate">{b.name}</p>
<p class="text-[8px] text-ink-subtle">{b.service}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import { Show } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { CheckCircleIcon, XCircleIcon, UsersIcon, ClockIcon, BellIcon, MoreHorizontalIcon } from "./icons";
export function ActivityTimeline(props: { activities: any[] }) {
const i18n = useI18n();
const getIcon = (type: string) => {
const base = "w-8 h-8 rounded-full flex items-center justify-center shrink-0";
switch (type) {
case "booking": return <div class={`${base} bg-accent-subtle text-accent`}><CheckCircleIcon /></div>;
case "cancel": return <div class={`${base} bg-canvas-muted text-ink-muted`}><XCircleIcon /></div>;
case "client": return <div class={`${base} bg-accent-subtle text-accent`}><UsersIcon /></div>;
case "reschedule": return <div class={`${base} bg-canvas-muted text-ink-muted`}><ClockIcon /></div>;
case "reminder": return <div class={`${base} bg-accent-subtle text-accent`}><BellIcon /></div>;
default: return <div class={`${base} bg-canvas-muted text-ink-muted`}><MoreHorizontalIcon /></div>;
}
};
return (
<div class="surface-card p-6 shadow-sm">
<h3 class="text-lg font-semibold text-ink mb-5">{i18n.t("dashboard.recentActivity")}</h3>
<Show when={props.activities.length > 0} fallback={
<div class="text-center py-8">
<p class="text-sm text-ink-muted">{i18n.t("dashboard.recentActivity.empty")}</p>
</div>
}>
<div class="space-y-0">
{props.activities.map((activity, index) => (
<div class="group flex gap-4 relative" style={{ "animation-delay": `${index * 75}ms` }}>
{index < props.activities.length - 1 && (
<div class="absolute left-4 top-8 bottom-0 w-px bg-border" />
)}
<div class="relative z-10 flex-shrink-0 transition-transform duration-300 group-hover:scale-110">
{getIcon(activity.type)}
</div>
<div class="flex-1 pb-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-ink group-hover:text-accent transition-colors">{activity.action}</p>
<p class="text-sm text-ink-muted mt-0.5 truncate">{activity.detail}</p>
</div>
<span class="text-xs text-ink-subtle whitespace-nowrap shrink-0">{activity.time}</span>
</div>
</div>
</div>
))}
</div>
</Show>
</div>
);
}
@@ -2,6 +2,15 @@ import { createSignal, createMemo, Show, For } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "./icons";
const CalendarIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
export function CalendarView(props: { bookings: any[]; locale?: string; onBookingClick?: (b: any) => void }) {
const i18n = useI18n();
const [currentDate, setCurrentDate] = createSignal(new Date());
@@ -10,25 +19,36 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
const [isAnimating, setIsAnimating] = createSignal(false);
const isCs = () => props.locale === "cs";
const weekDays = isCs()
? ["Po", "Út", "St", "Čt", "Pá", "So", "Ne"]
: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const calendarMonth = createMemo(() => currentDate().getMonth());
const calendarYear = createMemo(() => currentDate().getFullYear());
const monthYear = createMemo(() =>
new Intl.DateTimeFormat(isCs() ? "cs-CZ" : "en-US", { month: "long", year: "numeric" }).format(currentDate())
`${i18n.t(`calendar.months.${calendarMonth()}`)} ${calendarYear()}`
);
const calendarDays = createMemo(() => {
const date = currentDate();
const year = date.getFullYear();
const month = date.getMonth();
const firstDayOfMonth = new Date(year, month, 1).getDay();
// Adjust so Monday = 0
const firstDay = (firstDayOfMonth + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: Array<{ day: number | null; bookings: any[]; isToday: boolean }> = [];
const changeMonth = (direction: number) => {
setIsAnimating(true);
setTimeout(() => {
setCurrentDate(new Date(currentDate().getFullYear(), currentDate().getMonth() + direction, 1));
setIsAnimating(false);
setSelectedDay(null);
setModalDay(null);
}, 150);
};
for (let i = 0; i < firstDay; i++) days.push({ day: null, bookings: [], isToday: false });
const calendarDays = createMemo(() => {
const year = calendarYear();
const month = calendarMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDay = firstDay.getDay();
const adjustedStart = startingDay === 0 ? 6 : startingDay - 1;
const days: Array<{ day: number | null; bookings: any[]; isToday: boolean }> = [];
for (let i = 0; i < adjustedStart; i++) {
days.push({ day: null, bookings: [], isToday: false });
}
const today = new Date();
for (let day = 1; day <= daysInMonth; day++) {
@@ -42,16 +62,6 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
return days;
});
const changeMonth = (direction: number) => {
setIsAnimating(true);
setTimeout(() => {
setCurrentDate(new Date(currentDate().getFullYear(), currentDate().getMonth() + direction, 1));
setIsAnimating(false);
setSelectedDay(null);
setModalDay(null);
}, 150);
};
const handleDayClick = (day: number | null) => {
if (!day) return;
const dayData = calendarDays().find((d) => d.day === day);
@@ -73,77 +83,94 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
return calendarDays().find((d) => d.day === day)?.bookings ?? [];
});
// Booking visual density for bars
const getBookingBars = (bookings: any[]) => {
if (bookings.length === 0) return null;
const count = bookings.length;
const hasMultiple = count >= 2;
const hasMany = count >= 3;
return (
<div class="mt-2 space-y-1.5">
<div class="h-2 bg-accent/30 rounded-full w-full group-hover:bg-accent/40 transition-colors" />
{hasMultiple && <div class="h-2 bg-accent/20 rounded-full w-3/4 group-hover:bg-accent/30 transition-colors" />}
{hasMany && <div class="h-2 bg-accent/10 rounded-full w-1/2 group-hover:bg-accent/20 transition-colors" />}
</div>
);
};
return (
<>
<div class="surface-card p-6 shadow-sm hover:shadow-md transition-all duration-500">
{/* Header */}
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">{monthYear()}</h3>
<p class="text-sm text-ink-muted mt-0.5">
{props.bookings.length} {isCs() ? i18n.t("dashboard.calendar.bookingsCount") : "bookings"}
</p>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center text-accent">
<CalendarIcon />
</div>
<div>
<h3 class="text-lg font-semibold text-ink font-display">{monthYear()}</h3>
<p class="text-sm text-ink-muted mt-0.5">
{props.bookings.length} {isCs() ? i18n.t("dashboard.calendar.bookingsCount") : "bookings"}
</p>
</div>
</div>
<div class="flex gap-1">
<div class="flex items-center gap-2">
<button
onClick={() => changeMonth(-1)}
aria-label={i18n.t("dashboard.prevMonth")}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
aria-label={i18n.t("calendar.prevMonth")}
class="w-8 h-8 rounded-lg border border-border flex items-center justify-center text-ink-muted hover:bg-canvas-subtle transition-colors"
>
<ChevronLeftIcon />
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
<button
onClick={() => changeMonth(1)}
aria-label={i18n.t("dashboard.nextMonth")}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
aria-label={i18n.t("calendar.nextMonth")}
class="w-8 h-8 rounded-lg border border-border flex items-center justify-center text-ink-muted hover:bg-canvas-subtle transition-colors"
>
<ChevronRightIcon />
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-1 mb-2">
{weekDays.map((day) => (
<div class="text-center text-[10px] sm:text-xs font-semibold text-ink-subtle uppercase tracking-wider py-2">
{day}
{/* Day headers */}
<div class="grid grid-cols-7 gap-px bg-border/50">
{[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => (
<div class="bg-canvas-subtle/50 p-3 text-center text-xs font-medium text-ink-subtle uppercase tracking-wider">
{i18n.t(`calendar.days.${dayIndex}`)}
</div>
))}
</div>
<div class={`grid grid-cols-7 gap-1 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
{calendarDays().map(({ day, bookings, isToday }, index) => (
<button
onClick={() => handleDayClick(day)}
class={`
aspect-square p-1 sm:p-1.5 rounded-xl border transition-all duration-200 relative overflow-hidden
${day ? "bg-canvas border-border/60 hover:border-accent/40 hover:shadow-sm" : "bg-transparent border-transparent pointer-events-none"}
${isToday ? "ring-1 ring-accent bg-accent-subtle/40" : ""}
${selectedDay() === day ? "ring-2 ring-accent ring-offset-1 ring-offset-canvas" : ""}
${bookings.length > 0 && !isToday ? "bg-accent-subtle/20" : ""}
`}
style={{ "animation-delay": `${index * 15}ms` }}
disabled={!day}
>
{day && (
<>
<span class={`text-xs sm:text-sm font-medium ${isToday ? "text-accent" : "text-ink"}`}>{day}</span>
{bookings.length > 0 && (
<div class="absolute bottom-1 left-1 right-1 flex gap-0.5 justify-center">
{bookings.slice(0, 3).map((b: any) => (
<span
class={`w-1 h-1 rounded-full ${
b.status === "confirmed" ? "bg-ink/60" : b.status === "pending" ? "bg-ink/40" : "bg-ink/20"
}`}
/>
))}
{bookings.length > 3 && (
<span class="text-[7px] font-bold text-ink/50 leading-none">+</span>
)}
</div>
)}
</>
)}
</button>
))}
{/* Calendar grid */}
<div class={`grid grid-cols-7 gap-px bg-border/50 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
{calendarDays().map(({ day, bookings, isToday }) => {
if (!day) {
return <div class="p-3 min-h-[80px] lg:min-h-[100px] bg-canvas/50" />;
}
return (
<button
onClick={() => handleDayClick(day)}
class={`
p-3 min-h-[80px] lg:min-h-[100px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group relative text-left
${isToday ? "ring-2 ring-inset ring-accent bg-accent-soft" : ""}
${selectedDay() === day ? "ring-2 ring-inset ring-accent bg-accent-subtle/30" : ""}
${bookings.length > 0 && !isToday ? "bg-accent-subtle/10" : ""}
`}
>
<span class={`text-sm transition-colors ${isToday ? "font-semibold text-accent" : "text-ink-muted group-hover:text-ink"}`}>
{day}
</span>
{getBookingBars(bookings)}
{bookings.length > 0 && (
<div class="absolute top-2 right-2">
<span class="text-[10px] font-semibold text-accent bg-accent/10 px-1.5 py-0.5 rounded-full">
{bookings.length}
</span>
</div>
)}
</button>
);
})}
</div>
{/* Inline selected day preview for empty days */}
@@ -213,7 +240,7 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-ink">
{new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
{new Date(booking.startsAt).toLocaleTimeString(isCs() ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })}
</p>
<span
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
@@ -0,0 +1,37 @@
import { useI18n } from "../../providers/i18n-provider";
import { TrendingUpIcon, TrendingDownIcon } from "./icons";
export function KpiCard(props: { kpi: any; index: number }) {
const i18n = useI18n();
const trend = () => props.kpi.trend ?? "neutral";
const trendClass = () => {
switch (trend()) {
case "up": return "bg-accent/10 text-accent";
case "down": return "bg-red-500/10 text-red-500";
default: return "bg-canvas-muted text-ink-muted";
}
};
const TrendIcon = () => {
switch (trend()) {
case "up": return <TrendingUpIcon />;
case "down": return <TrendingDownIcon />;
default: return <span class="text-xs">\u2014</span>;
}
};
return (
<div
class="surface-card p-5 hover:shadow-md transition-shadow animate-fade-in"
style={{ "animation-delay": `${props.index * 100}ms` }}
>
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-ink-muted">{props.kpi.label}</p>
<span class={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${trendClass()}`}>
<TrendIcon /> {props.kpi.change}
</span>
</div>
<p class="text-3xl font-bold text-ink font-display">{props.kpi.value}</p>
</div>
);
}
@@ -1,6 +1,7 @@
import { createSignal, createMemo, Show, For } from "solid-js";
import { createSignal, createMemo, Show } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { BellIcon } from "./icons";
import { AnimatedList } from "../animated-list";
interface NotificationItem {
id: string;
@@ -138,8 +139,11 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any })
</div>
}
>
<For each={filteredNotifications()}>
{(n) => (
<AnimatedList
items={filteredNotifications()}
animation="scale"
gap={0}
renderItem={(n) => (
<button
onClick={() => markRead(n.id)}
class={`w-full text-left flex items-start gap-3 p-4 hover:bg-canvas-subtle/50 transition-colors border-b border-border/50 last:border-0 ${n.read ? "opacity-60" : ""}`}
@@ -155,7 +159,7 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any })
{!n.read && <span class="w-2 h-2 rounded-full bg-accent shrink-0 mt-1" />}
</button>
)}
</For>
/>
</Show>
</div>
</div>
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings" | "analytics";
export type BookingStatus = "confirmed" | "pending" | "cancelled";
export interface KpiData {
@@ -0,0 +1,83 @@
import { Show, type JSX } from "solid-js";
export interface DockItem {
id: string;
label: string;
icon: JSX.Element;
href?: string;
onClick?: () => void;
variant?: "default" | "danger";
}
export interface FloatingDockProps {
menuItems: DockItem[];
bottomActions?: DockItem[];
activeId?: string;
className?: string;
}
export function FloatingDock(props: FloatingDockProps) {
return (
<div class={`flex flex-col overflow-hidden rounded-3xl bg-canvas border border-border shadow-xl ${props.className ?? ""}`}>
{/* Menu items */}
<div class="flex-1 overflow-y-auto p-4 pb-2">
<div class="flex flex-col gap-0.5">
{props.menuItems.map((item) => {
const content = (
<div
class={`flex h-10 cursor-pointer items-center justify-between gap-2 rounded-xl px-2 text-sm font-medium transition-colors ${
item.variant === "danger"
? "text-error hover:bg-error-subtle"
: "text-ink hover:bg-canvas-subtle"
} ${props.activeId === item.id ? "bg-canvas-subtle" : ""}`}
onClick={item.onClick}
>
<div class="flex items-center gap-2">
<span class="text-ink-muted">{item.icon}</span>
<span class="capitalize">{item.label}</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-ink-subtle">
<path d="m9 18 6-6-6-6"/>
</svg>
</div>
);
if (item.href) {
return (
<a href={item.href} class="block">
{content}
</a>
);
}
return content;
})}
</div>
</div>
{/* Bottom action bar */}
<Show when={props.bottomActions && props.bottomActions.length > 0}>
<div class="shrink-0 border-t border-border/60 p-2">
<div class="flex h-9 w-full items-center justify-center gap-1">
{props.bottomActions!.map((action) => (
<button
onClick={action.onClick}
class={`flex h-full cursor-pointer items-center rounded-2xl text-sm font-medium transition-colors duration-300 text-ink-subtle hover:bg-canvas-subtle hover:text-ink px-2 ${
props.activeId === action.id ? "bg-ink/[0.04] text-ink" : ""
}`}
aria-label={action.label}
>
<span class="size-4">{action.icon}</span>
{props.activeId === action.id && (
<span class="ml-1.5 overflow-hidden whitespace-nowrap font-medium tracking-tight text-xs">
{action.label}
</span>
)}
</button>
))}
</div>
</div>
</Show>
</div>
);
}
@@ -0,0 +1,95 @@
import { Show, type JSX } from "solid-js";
export interface HoverFeatureItem {
name: string;
description: string;
href?: string;
soon?: boolean;
children?: JSX.Element;
containerClassName?: string;
fadeBottom?: boolean;
}
export interface HoverFeatureCardsProps {
items: HoverFeatureItem[];
className?: string;
}
function HoverFeatureCard(props: { item: HoverFeatureItem }) {
const { item } = props;
const inner = (
<div
class={`group flex flex-col w-full relative transition-transform duration-300 ease-out ${
item.soon ? "opacity-70 cursor-not-allowed" : item.href ? "cursor-pointer" : ""
}`}
classList={{
"hover:-translate-y-1": !item.soon,
}}
>
<div
class={`flex flex-col rounded-2xl border h-72 bg-canvas transition-all duration-300 w-full overflow-hidden ${
!item.soon && item.href ? "hover:border-accent/40 hover:shadow-lg" : ""
} ${item.soon ? "border-dashed border-border" : "border-border"}`}
>
<Show when={item.soon}>
<span class="absolute top-3 right-3 z-10 text-xs text-ink-subtle border border-border rounded-full px-2.5 py-1 bg-canvas-subtle">
Coming soon
</span>
</Show>
<div
class={`relative w-full flex-1 overflow-hidden px-5 pt-6 pb-4 flex flex-col gap-3 ${
item.containerClassName ?? ""
}`}
>
<span
class={`font-display font-semibold text-xl tracking-tight ${
item.soon ? "text-ink-subtle" : "text-ink"
}`}
>
{item.name}
</span>
<Show when={item.children}>
<div class="flex-1 min-h-0">{item.children}</div>
</Show>
<Show when={item.fadeBottom}>
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-canvas to-transparent" />
</Show>
</div>
</div>
{/* Description panel that slides up on hover */}
<div
class="overflow-hidden w-11/12 self-center transition-all duration-300 ease-out translate-y-[-16px] opacity-0 group-hover:translate-y-0 group-hover:opacity-100"
>
<div class="py-3 px-5 relative border border-t-0 rounded-b-2xl bg-canvas-subtle/60">
<div class="pointer-events-none w-[103%] bg-gradient-to-b from-canvas-subtle/60 to-transparent h-10 absolute -top-1 -left-1" />
<p class="text-sm text-ink-muted leading-relaxed">{item.description}</p>
</div>
</div>
</div>
);
if (item.href && !item.soon) {
return (
<a href={item.href} class="block">
{inner}
</a>
);
}
return inner;
}
export function HoverFeatureCards(props: HoverFeatureCardsProps) {
return (
<div class={`grid grid-cols-1 sm:grid-cols-2 gap-5 w-full ${props.className ?? ""}`}>
{props.items.map((item) => (
<HoverFeatureCard item={item} />
))}
</div>
);
}
+1
View File
@@ -2,3 +2,4 @@ export { BookraCharacter } from "./bookra-character";
export { LocationMap } from "./location-map";
export { WidgetBuilder } from "./widget-builder";
export { IntegrationModal } from "./integration-modal";
export { FloatingDock } from "./floating-dock";
@@ -1,4 +1,5 @@
import { createSignal, For } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Button, Tabs, TabsList, TabsTrigger, TabsContent, DialogCloseButton } from "./ui";
interface IntegrationModalProps {
@@ -11,6 +12,10 @@ interface IntegrationModalProps {
}
export function IntegrationModal(props: IntegrationModalProps) {
const i18n = useI18n();
const t = (key: string) => i18n.t(key);
const isCs = () => i18n.locale() === "cs";
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
const copyToClipboard = async (text: string, type: string) => {
@@ -85,31 +90,33 @@ add_action('wp_footer', function() {
<?php
});`;
const embedTabs = [
{ id: "html", label: t("integration.embed.html"), code: htmlWidgetCode },
{ id: "react", label: t("integration.embed.react"), code: reactWidgetCode },
{ id: "solid", label: t("integration.embed.solid"), code: solidWidgetCode },
{ id: "php", label: t("integration.embed.php"), code: phpWidgetCode },
];
return (
<Dialog open={props.isOpen} onClose={props.onClose}>
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="text-2xl font-display">
Add Bookra to Your Website
</DialogTitle>
<DialogDescription>
Choose how you want to integrate Bookra with your business. Share a link or embed directly on your website.
</DialogDescription>
<DialogTitle class="text-2xl font-display">{t("integration.title")}</DialogTitle>
<DialogDescription>{t("integration.subtitle")}</DialogDescription>
</DialogHeader>
<DialogCloseButton onClose={props.onClose} />
<Tabs defaultValue="hosted" class="mt-6">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="hosted">Hosted Page</TabsTrigger>
<TabsTrigger value="embed">Embed Widget</TabsTrigger>
<TabsTrigger value="hosted">{t("integration.tab.hosted")}</TabsTrigger>
<TabsTrigger value="embed">{t("integration.tab.embed")}</TabsTrigger>
</TabsList>
{/* Hosted Page Tab */}
<TabsContent value="hosted" class="space-y-6 mt-6">
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-display font-semibold text-ink mb-3">Your Booking Page</h4>
<p class="text-sm text-ink-muted mb-4">
Share this link with your customers. They can book directly without any setup on your website.
</p>
<h4 class="font-display font-semibold text-ink mb-3">{t("integration.hosted.title")}</h4>
<p class="text-sm text-ink-muted mb-4">{t("integration.hosted.desc")}</p>
<div class="flex items-center gap-3 p-4 bg-canvas rounded-xl border border-border">
<code class="flex-1 text-sm text-ink truncate font-mono">{hostedPageUrl}</code>
@@ -118,7 +125,7 @@ add_action('wp_footer', function() {
size="sm"
onClick={() => copyToClipboard(hostedPageUrl, "url")}
>
{copiedSnippet() === "url" ? "Copied!" : "Copy"}
{copiedSnippet() === "url" ? t("common.copied") : t("common.copy")}
</Button>
</div>
@@ -127,7 +134,29 @@ add_action('wp_footer', function() {
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<span>Perfect for social media, email signatures, or direct sharing</span>
<span>{t("integration.hosted.hint")}</span>
</div>
</div>
{/* Demo preview card */}
<div class="p-5 bg-gradient-to-br from-accent-subtle to-accent-soft rounded-xl border border-accent/10">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center text-accent shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</div>
<div class="flex-1">
<h5 class="font-display font-semibold text-ink mb-1">{t("integration.demo.title")}</h5>
<p class="text-sm text-ink-muted mb-3">{t("integration.demo.desc")}</p>
<a
href={hostedPageUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-sm font-medium text-accent hover:text-accent-hover transition-colors"
>
{isCs() ? "Otevřít stránku" : "Open page"}
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
</div>
</div>
</div>
@@ -141,54 +170,47 @@ add_action('wp_footer', function() {
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Share on Facebook
{t("integration.share.facebook")}
</a>
<a
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(`Book your appointment with ${props.tenantName}!`)}&url=${encodeURIComponent(hostedPageUrl)}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 p-3 rounded-xl bg-[hsl(200,15%,10%)]/10 text-ink hover:bg-[hsl(200,15%,10%)]/20 transition-colors"
class="flex items-center justify-center gap-2 p-3 rounded-xl bg-ink/5 text-ink hover:bg-ink/10 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Share on X
{t("integration.share.x")}
</a>
</div>
</TabsContent>
{/* Embed Widget Tab */}
<TabsContent value="embed" class="space-y-6 mt-6">
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-display font-semibold text-ink mb-3">Embed on Your Website</h4>
<p class="text-sm text-ink-muted mb-4">
Add the booking widget directly to your website. Choose your platform:
</p>
<h4 class="font-display font-semibold text-ink mb-3">{t("integration.embed.title")}</h4>
<p class="text-sm text-ink-muted mb-4">{t("integration.embed.desc")}</p>
<Tabs defaultValue="html" class="w-full">
<TabsList class="grid w-full grid-cols-4 mb-4">
<TabsTrigger value="html">HTML/JS</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="solid">SolidJS</TabsTrigger>
<TabsTrigger value="php">PHP/WordPress</TabsTrigger>
<For each={embedTabs}>
{(tab) => <TabsTrigger value={tab.id}>{tab.label}</TabsTrigger>}
</For>
</TabsList>
<For each={[
{ id: "html", label: "HTML/JavaScript", code: htmlWidgetCode },
{ id: "react", label: "React", code: reactWidgetCode },
{ id: "solid", label: "SolidJS", code: solidWidgetCode },
{ id: "php", label: "PHP/WordPress", code: phpWidgetCode },
]}>
{(item) => (
<TabsContent value={item.id} class="mt-0">
<For each={embedTabs}>
{(tab) => (
<TabsContent value={tab.id} class="mt-0">
<div class="relative">
<pre class="p-4 bg-ink text-canvas rounded-xl overflow-x-auto text-sm font-mono"><code>{item.code}</code></pre>
<pre class="p-4 bg-ink text-canvas rounded-xl overflow-x-auto text-sm font-mono"><code>{tab.code}</code></pre>
<Button
variant="secondary"
size="sm"
class="absolute top-3 right-3"
onClick={() => copyToClipboard(item.code, item.id)}
onClick={() => copyToClipboard(tab.code, tab.id)}
>
{copiedSnippet() === item.id ? "Copied!" : "Copy"}
{copiedSnippet() === tab.id ? t("common.copied") : t("common.copy")}
</Button>
</div>
</TabsContent>
@@ -196,17 +218,18 @@ add_action('wp_footer', function() {
</For>
</Tabs>
<div class="mt-4 p-4 bg-[hsl(var(--info-subtle))] rounded-xl border border-[hsl(var(--info))/20]">
<div class="mt-4 p-4 bg-info-subtle rounded-xl border border-info/20">
<div class="flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-[hsl(var(--info))] mt-0.5 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-info mt-0.5 shrink-0">
<circle cx="12" cy="12" r="10"/>
<line x1="12" x2="12" y1="16" y2="12"/>
<line x1="12" x2="12.01" y1="8" y2="8"/>
</svg>
<div>
<p class="text-sm font-medium text-ink">Need help with installation?</p>
<p class="text-sm font-medium text-ink">{t("integration.help.title")}</p>
<p class="text-sm text-ink-muted mt-1">
Contact our support team at <a href="mailto:support@bookra.eu" class="text-accent hover:underline">support@bookra.eu</a> for assistance with embedding the widget.
{t("integration.help.desc")}{" "}
<a href="mailto:support@bookra.eu" class="text-accent hover:underline">support@bookra.eu</a>
</p>
</div>
</div>
+11 -2
View File
@@ -1,4 +1,5 @@
import { Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { useTheme } from "../providers/theme-provider";
import {
DEFAULT_MAP_STYLE_ID,
resolveMapTileStyle,
@@ -95,8 +96,16 @@ export function LocationMap(props: LocationMapProps) {
const [isReady, setIsReady] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const theme = useTheme();
const isDark = () => theme.resolvedTheme() === "dark";
const zoom = () => props.zoom ?? props.coordinates.zoom ?? 15;
const mapStyle = () => resolveMapTileStyle(props.mapStyle ?? DEFAULT_MAP_STYLE_ID, props.customTileUrl);
const resolvedStyleId = () => {
const styleId = props.mapStyle ?? DEFAULT_MAP_STYLE_ID;
if (styleId === "positron" && isDark()) return "dark";
return styleId;
};
const mapStyle = () => resolveMapTileStyle(resolvedStyleId(), props.customTileUrl);
const popupContent = () => {
const label = props.markerLabel ?? props.coordinates.address;
const address = props.coordinates.address && props.coordinates.address !== label ? props.coordinates.address : "";
@@ -192,7 +201,7 @@ export function LocationMap(props: LocationMapProps) {
return (
<div
class={`bookra-location-map relative overflow-hidden rounded-card border border-border bg-canvas-subtle ${props.class ?? ""}`}
class={`bookra-location-map relative overflow-hidden rounded-2xl border border-border shadow-sm bg-canvas-subtle ${props.class ?? ""}`}
style={{ height: `${props.height ?? 360}px` }}
role="img"
aria-label={props.coordinates.address ? `Map for ${props.coordinates.address}` : "Location map"}
@@ -0,0 +1,142 @@
import { createSignal, For, Show, type JSX } from "solid-js";
export interface PinnedListItem {
id: string;
name: string;
subtitle: string;
icon?: JSX.Element;
href?: string;
}
export interface PinnedListProps {
items: PinnedListItem[];
className?: string;
pinnedLabel?: string;
allLabel?: string;
/** Called when pin state changes. Returns new Set of pinned IDs. */
onPinChange?: (pinnedIds: Set<string>) => void;
/** Initial pinned IDs */
initialPinned?: string[];
}
export function PinnedList(props: PinnedListProps) {
const [pinnedIds, setPinnedIds] = createSignal<Set<string>>(
new Set(props.initialPinned ?? [])
);
const togglePin = (id: string) => {
setPinnedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
props.onPinChange?.(next);
return next;
});
};
const pinned = () => props.items.filter((i) => pinnedIds().has(i.id));
const unpinned = () => props.items.filter((i) => !pinnedIds().has(i.id));
const pinnedLabel = () => props.pinnedLabel ?? "Pinned";
const allLabel = () => props.allLabel ?? "All Items";
const isCs = () => props.pinnedLabel === "Připnuto"; // crude locale detection
const DotToggle = (p: { id: string }) => {
const isPinned = () => pinnedIds().has(p.id);
return (
<button
type="button"
onClick={() => togglePin(p.id)}
class={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full transition-all duration-200 ${
isPinned()
? "bg-accent text-canvas hover:bg-accent-hover"
: "bg-canvas-subtle border border-border text-ink-subtle hover:border-accent/40 hover:text-accent"
}`}
aria-label={isPinned() ? "Unpin" : "Pin"}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
class="transition-transform duration-200"
>
<circle cx="5" cy="5" r="4" class={isPinned() ? "" : "opacity-0"} />
<circle
cx="5"
cy="5"
r="3.5"
fill="none"
stroke="currentColor"
stroke-width="1"
class={isPinned() ? "opacity-0" : ""}
/>
</svg>
</button>
);
};
const ItemCard = (p: { item: PinnedListItem; pinned: boolean }) => {
const content = (
<div
class={`flex items-center gap-3 rounded-xl px-3 py-3 border transition-all duration-200 ${
p.pinned
? "bg-accent-soft border-accent/20"
: "bg-canvas-subtle/40 border-border/50 hover:border-border"
}`}
>
<Show when={p.item.icon}>
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-canvas border border-border text-ink-muted">
{p.item.icon}
</div>
</Show>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink">{p.item.name}</p>
<p class="truncate text-xs text-ink-muted">{p.item.subtitle}</p>
</div>
<DotToggle id={p.item.id} />
</div>
);
if (p.item.href) {
return (
<a href={p.item.href} class="block">
{content}
</a>
);
}
return content;
};
return (
<div class={`flex w-full flex-col gap-1.5 ${props.className ?? ""}`}>
<Show when={pinned().length > 0}>
<p class="px-1 pb-0.5 pt-1 text-xs font-medium text-accent uppercase tracking-wide">
{pinnedLabel()}
</p>
<For each={pinned()}>
{(item) => <ItemCard item={item} pinned={true} />}
</For>
</Show>
<Show when={unpinned().length > 0}>
<p
class={`px-1 pb-0.5 text-xs font-medium text-ink-subtle uppercase tracking-wide ${
pinned().length > 0 ? "pt-3" : "pt-1"
}`}
>
{allLabel()}
</p>
<For each={unpinned()}>
{(item) => <ItemCard item={item} pinned={false} />}
</For>
</Show>
</div>
);
}
+29 -10
View File
@@ -87,6 +87,7 @@ export const Shell: ParentComponent = (props) => {
const [authNotice, setAuthNotice] = createSignal<string | null>(null);
const [authSubmitting, setAuthSubmitting] = createSignal(false);
const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in";
const isDark = () => theme.resolvedTheme() === "dark";
const navLinks = [
{ href: "/pricing", label: i18n.t("nav.pricing") },
@@ -217,11 +218,20 @@ export const Shell: ParentComponent = (props) => {
href="/"
onClick={() => window.scrollTo(0, 0)}
>
<img
src="/bookra-logo.svg"
alt="Bookra"
class="h-8 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
/>
<div class="relative h-8">
<img
src="/bookra-illustrations/logo_text_horizontal.svg"
alt="Bookra"
class="h-8 w-auto absolute inset-0 transition-opacity duration-200"
classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }}
/>
<img
src="/bookra-illustrations/logo_text_horizontal_white.svg"
alt="Bookra"
class="h-8 w-auto absolute inset-0 transition-opacity duration-200"
classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }}
/>
</div>
</A>
{/* Desktop Navigation */}
@@ -419,11 +429,20 @@ export const Shell: ParentComponent = (props) => {
{/* Logo & Description */}
<div class="md:col-span-2 space-y-4">
<A href="/" class="inline-flex items-center gap-4 group" onClick={() => window.scrollTo(0, 0)}>
<img
src="/bookra-logo.svg"
alt="Bookra"
class="h-10 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
/>
<div class="relative h-10">
<img
src="/bookra-illustrations/logo_text_horizontal.svg"
alt="Bookra"
class="h-10 w-auto absolute inset-0 transition-opacity duration-200"
classList={{ "opacity-0": isDark(), "opacity-90": !isDark() }}
/>
<img
src="/bookra-illustrations/logo_text_horizontal_white.svg"
alt="Bookra"
class="h-10 w-auto absolute inset-0 transition-opacity duration-200"
classList={{ "opacity-0": !isDark(), "opacity-90": isDark() }}
/>
</div>
</A>
<p class="text-ink-muted max-w-sm leading-relaxed">
{i18n.t("footer.description")}
@@ -0,0 +1,278 @@
import { createSignal, Show, onMount, onCleanup, createMemo } from "solid-js";
const CANVAS_W = 64;
const CANVAS_H = 36;
export interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
blurAmount?: number;
intensity?: number;
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
export function VideoPlayer(props: VideoPlayerProps) {
const blurAmount = () => props.blurAmount ?? 60;
const intensity = () => props.intensity ?? 0.85;
const [playing, setPlaying] = createSignal(false);
const [currentTime, setCurrentTime] = createSignal(0);
const [duration, setDuration] = createSignal(0);
const [isDragging, setIsDragging] = createSignal(false);
let videoRef: HTMLVideoElement | undefined;
let canvasRef: HTMLCanvasElement | undefined;
let trackRef: HTMLDivElement | undefined;
let rafId: number;
const progress = createMemo(() => {
const dur = duration();
if (!dur) return 0;
return (currentTime() / dur) * 100;
});
onMount(() => {
const video = videoRef;
const canvas = canvasRef;
if (!video || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const draw = () => {
if (!video.paused || video.readyState >= 2) {
try {
ctx.drawImage(video, 0, 0, CANVAS_W, CANVAS_H);
} catch {
// ignore cross-origin canvas restrictions
}
}
rafId = requestAnimationFrame(draw);
};
rafId = requestAnimationFrame(draw);
});
onCleanup(() => {
cancelAnimationFrame(rafId);
});
const togglePlay = () => {
if (!videoRef) return;
if (videoRef.paused) {
void videoRef.play();
setPlaying(true);
} else {
videoRef.pause();
setPlaying(false);
}
};
const onVideoEnded = () => setPlaying(false);
const onTimeUpdate = () => {
if (videoRef && !isDragging()) {
setCurrentTime(videoRef.currentTime);
}
};
const onLoadedMetadata = () => {
if (videoRef) {
setDuration(videoRef.duration);
}
};
const seekTo = (clientX: number) => {
const track = trackRef;
const video = videoRef;
if (!track || !video) return;
const rect = track.getBoundingClientRect();
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
video.currentTime = pct * duration();
setCurrentTime(video.currentTime);
};
const onTrackMouseDown = (e: MouseEvent) => {
e.preventDefault();
setIsDragging(true);
seekTo(e.clientX);
const onMouseMove = (ev: MouseEvent) => seekTo(ev.clientX);
const onMouseUp = () => {
setIsDragging(false);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
const onTrackTouchStart = (e: TouchEvent) => {
setIsDragging(true);
seekTo(e.touches[0].clientX);
const onTouchMove = (ev: TouchEvent) => seekTo(ev.touches[0].clientX);
const onTouchEnd = () => {
setIsDragging(false);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
};
window.addEventListener("touchmove", onTouchMove);
window.addEventListener("touchend", onTouchEnd);
};
return (
<div class={`relative rounded-2xl overflow-hidden bg-canvas border border-border/60 shadow-lg ${props.className ?? ""}`}>
{/* Ambient glow canvas */}
<canvas
ref={(el) => { canvasRef = el; }}
width={CANVAS_W}
height={CANVAS_H}
aria-hidden="true"
class="absolute pointer-events-none rounded-2xl"
style={{
inset: 0,
width: "100%",
height: "100%",
filter: `blur(${blurAmount()}px)`,
opacity: intensity(),
transform: "scale(1.08)",
"z-index": 0,
}}
/>
{/* Main video */}
<div class="relative w-full" style={{ "z-index": 1 }}>
<video
ref={(el) => { videoRef = el; }}
src={props.src}
poster={props.poster}
playsinline
preload="metadata"
class="w-full block bg-canvas rounded-2xl"
onEnded={onVideoEnded}
onClick={togglePlay}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
/>
<button
onClick={togglePlay}
aria-label={playing() ? "Pause" : "Play"}
class="absolute inset-0 w-full h-full bg-transparent cursor-pointer focus:outline-none group"
>
<Show when={!playing()}>
<span
class="absolute inset-0 flex items-center justify-center pointer-events-none transition-all duration-300"
style={{ opacity: 1, transform: "none" }}
>
<span class="bg-black/40 rounded-full p-4 backdrop-blur-sm group-hover:scale-110 transition-transform">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="white"
stroke="none"
aria-hidden="true"
>
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
</svg>
</span>
</span>
</Show>
<Show when={playing()}>
<span
class="absolute inset-0 flex items-center justify-center pointer-events-none transition-all duration-300 opacity-0 group-hover:opacity-100"
>
<span class="bg-black/40 rounded-full p-4 backdrop-blur-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="white"
stroke="none"
aria-hidden="true"
>
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
</span>
</span>
</Show>
</button>
</div>
{/* Scrubber */}
<div class="pt-2">
<div class="w-full">
<div
class="relative flex w-full items-center h-10 touch-none cursor-grab select-none"
onMouseDown={onTrackMouseDown}
onTouchStart={onTrackTouchStart}
>
{/* Current time */}
<span
class="absolute left-0 top-0 transition-colors pointer-events-none rounded-md font-medium px-2 py-1 text-xs tabular-nums text-foreground"
style={{ opacity: 0.8, "background-color": "transparent" }}
>
{formatTime(currentTime())}
</span>
{/* Duration */}
<span
class="absolute right-0 top-0 transition-colors pointer-events-none rounded-md px-2 py-1 text-xs font-medium tabular-nums text-foreground"
style={{ "background-color": "transparent" }}
>
{formatTime(duration())}
</span>
{/* Track */}
<div
ref={(el) => { trackRef = el; }}
class="relative w-full overflow-hidden rounded-xl bg-foreground/30"
style={{ height: "6px", opacity: 0.8 }}
>
{/* Fill */}
<div
class="absolute left-0 top-0 h-full rounded-xl dark:bg-white bg-black transition-[width] duration-75"
style={{ width: `${progress()}%` }}
/>
{/* Buffered / ghost */}
<div
class="absolute h-full pointer-events-none rounded-xl"
style={{ "background-color": "color-mix(in srgb, var(--foreground) 20%, transparent)", left: `${progress()}%`, width: "0px", opacity: 0 }}
/>
</div>
{/* Thumb */}
<span
class="flex items-center justify-center pointer-events-none absolute top-1/2"
style={{
width: "18px",
height: "18px",
"margin-top": "-9px",
left: `${progress()}%`,
transform: "translateX(-50%)",
"z-index": 10,
transition: isDragging() ? "none" : "left 75ms linear",
}}
>
<span
class="block rounded-xl transition-colors bg-background dark:bg-white"
style={{ width: "14px", height: "14px" }}
/>
</span>
</div>
</div>
</div>
</div>
);
}
@@ -274,8 +274,8 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
map: {
type: "map",
icon: MapIcon,
title: "Location map",
description: "Embed a styled map for your real address.",
title: i18n.t("widget.type.map.title"),
description: i18n.t("widget.type.map.desc"),
},
};
@@ -285,7 +285,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
try {
const result = await resolveLocationInput(mapLocationInput());
if (!result) {
setMapMessage("No location found. Paste a Google Maps/Mapy.cz link, coordinates, or full address.");
setMapMessage(i18n.t("widget.map.noLocation"));
return;
}
setMapCoordinates({
@@ -293,7 +293,7 @@ export function WidgetBuilder(props: WidgetBuilderProps) {
zoom: result.zoom ?? mapZoom(),
});
setMapZoom(result.zoom ?? mapZoom());
setMapMessage(result.address ? `Resolved: ${result.address}` : "Location resolved.");
setMapMessage(result.address ? `${i18n.t("widget.map.resolved")}: ${result.address}` : i18n.t("widget.map.resolved"));
} catch (error) {
setMapMessage(error instanceof Error ? error.message : "Location search failed.");
} finally {
@@ -427,15 +427,17 @@ export function BookraLocationMap() {
};
// Drag and drop handlers
const [dropTargetIndex, setDropTargetIndex] = createSignal<number | null>(null);
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault();
setDropTargetIndex(index);
const draggedIdx = draggedIndex();
if (draggedIdx === null || draggedIdx === index) return;
const newOrder = [...widgetOrder()];
const [removed] = newOrder.splice(draggedIdx, 1);
newOrder.splice(index, 0, removed);
@@ -445,6 +447,7 @@ export function BookraLocationMap() {
const handleDragEnd = () => {
setDraggedIndex(null);
setDropTargetIndex(null);
};
const generateCode = (type: WidgetType, format: CodeFormat) => {
@@ -1123,7 +1126,7 @@ export class BookraWidgetComponent implements OnInit {
const Icon = option.icon;
return (
<div
draggable
draggable={true}
onDragStart={() => handleDragStart(index())}
onDragOver={(e) => handleDragOver(e, index())}
onDragEnd={handleDragEnd}
@@ -1131,7 +1134,7 @@ export class BookraWidgetComponent implements OnInit {
selectedType() === type
? "border-accent bg-accent/5"
: "border-border hover:border-accent/50 hover:bg-canvas-subtle"
} ${draggedIndex() === index() ? "opacity-50" : ""}`}
} ${draggedIndex() === index() ? "opacity-40 scale-[0.98] shadow-lg ring-2 ring-accent/20" : ""} ${dropTargetIndex() === index() && draggedIndex() !== index() ? "border-accent/60 bg-accent/5" : ""}`}
onClick={() => setSelectedType(type)}
>
<div class="flex items-center gap-3">
+8 -1
View File
@@ -22,9 +22,16 @@ export interface MapTileStyle {
tileClassName?: string;
}
export const DEFAULT_MAP_STYLE_ID = "bookra-voyager";
export const DEFAULT_MAP_STYLE_ID = "positron";
export const MAP_TILE_STYLES: readonly MapTileStyle[] = [
{
id: "positron",
name: "Minimal",
description: "Ultra-clean light basemap. Perfect for shadcn-style dashboards.",
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
attribution: "&copy; OpenStreetMap contributors &copy; CARTO",
},
{
id: "bookra-voyager",
name: "Bookra Voyager",
+11 -1
View File
@@ -12,7 +12,12 @@ const PricingRoute = lazy(() => import("./routes/pricing-route"));
const AboutRoute = lazy(() => import("./routes/about-route").then((module) => ({ default: module.AboutRoute })));
const AuthCallbackRoute = lazy(() => import("./routes/auth-callback-route").then((module) => ({ default: module.AuthCallbackRoute })));
const ContactRoute = lazy(() => import("./routes/contact-route").then((module) => ({ default: module.ContactRoute })));
const DashboardRoute = lazy(() => import("./routes/dashboard-route").then((module) => ({ default: module.DashboardRoute })));
const DashboardRoute = lazy(() => import("./routes/dashboard/overview-page"));
const DashboardBookingsRoute = lazy(() => import("./routes/dashboard/bookings-page"));
const DashboardCustomersRoute = lazy(() => import("./routes/dashboard/customers-page"));
const DashboardZonesRoute = lazy(() => import("./routes/dashboard/zones-page"));
const DashboardBillingRoute = lazy(() => import("./routes/dashboard/billing-page"));
const DashboardSettingsRoute = lazy(() => import("./routes/dashboard/settings-page"));
const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute })));
const BookingManageRoute = lazy(() => import("./routes/booking-manage-route").then((module) => ({ default: module.BookingManageRoute })));
const LegalRoute = lazy(() => import("./routes/legal-route").then((module) => ({ default: module.LegalRoute })));
@@ -27,6 +32,11 @@ render(
<Route path="/auth/callback" component={AuthCallbackRoute} />
<Route path="/contact" component={ContactRoute} />
<Route path="/dashboard" component={DashboardRoute} />
<Route path="/dashboard/bookings" component={DashboardBookingsRoute} />
<Route path="/dashboard/customers" component={DashboardCustomersRoute} />
<Route path="/dashboard/zones" component={DashboardZonesRoute} />
<Route path="/dashboard/billing" component={DashboardBillingRoute} />
<Route path="/dashboard/settings" component={DashboardSettingsRoute} />
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
<Route path="/manage/:reference" component={BookingManageRoute} />
<Route path="/:kind" component={LegalRoute} matchFilters={{ kind: ["privacy", "terms"] }} />
+54 -4
View File
@@ -78,7 +78,7 @@ const dictionaries = {
// Hero Section
"home.badge": "Nyní zdarma pro začátečníky",
"home.hero.title": "Klidný rezervační software pro lokální služby",
"home.hero.title": "Jednoduchý rezervační software pro lokální služby",
"home.hero.subtitle": "Spravujte rezervace, zákazníky a tým na jednom místě. Bez zbytečné složitosti — jen spolehlivý systém, který funguje.",
"home.hero.cta.primary": "Začít zdarma",
"home.hero.cta.secondary": "Otevřít rezervaci",
@@ -211,6 +211,10 @@ const dictionaries = {
"widget.type.floating.title": "Plovoucí bublina",
"widget.type.floating.desc": "Plovoucí tlačítko v rohu obrazovky",
"widget.type.floating.preview": "Nejlepší pro: E-shopy, kontinuální dostupnost",
"widget.type.map.title": "Mapa lokace",
"widget.type.map.desc": "Vložte stylizovanou mapu s vaší reálnou adresou.",
"widget.map.noLocation": "Lokace nenalezena. Vložte odkaz Google Maps/Mapy.cz, souřadnice nebo celou adresu.",
"widget.map.resolved": "Lokace nalezena",
"widget.button.text": "Rezervovat termín",
"widget.modal.trigger": "Otevřít rezervaci",
"widget.styling.title": "Vzhled",
@@ -248,11 +252,32 @@ const dictionaries = {
"common.copy": "Kopírovat",
"common.copied": "Zkopírováno!",
// Integration Modal
"integration.title": "Přidat Bookra na váš web",
"integration.subtitle": "Vyberte, jak chcete Bookra integrovat. Sdílejte odkaz nebo vložte přímo na web.",
"integration.tab.hosted": "Váš odkaz",
"integration.tab.embed": "Vložit widget",
"integration.hosted.title": "Vaše rezervační stránka",
"integration.hosted.desc": "Sdílejte tento odkaz zákazníkům. Rezervují bez jakéhokoli nastavení.",
"integration.hosted.hint": "Ideální pro sociální sítě, podpis e-mailu nebo přímé sdílení",
"integration.share.facebook": "Sdílet na Facebooku",
"integration.share.x": "Sdílet na X",
"integration.embed.title": "Vložit na web",
"integration.embed.desc": "Přidejte rezervační widget přímo na váš web. Vyberte platformu:",
"integration.embed.html": "HTML/JS",
"integration.embed.react": "React",
"integration.embed.solid": "SolidJS",
"integration.embed.php": "PHP/WordPress",
"integration.help.title": "Potřebujete pomoc s instalací?",
"integration.help.desc": "Napište nám na",
"integration.demo.title": "Rezervační stránka",
"integration.demo.desc": "Zákazníci rezervují přes váš odkaz. Žádná instalace.",
// Footer
"footer.copyright": "© 2026 Bookra. Všechna práva vyhrazena.",
"footer.privacy": "Ochrana soukromí",
"footer.terms": "Podmínky použití",
"footer.description": "Klidný rezervační software pro lokální služby. Spravujte rezervace, zákazníky a tým na jednom místě.",
"footer.description": "Jednoduchý rezervační software pro lokální služby. Spravujte rezervace, zákazníky a tým na jednom místě.",
"footer.links.title": "Navigace",
"footer.legal.title": "Právní informace",
@@ -558,7 +583,7 @@ const dictionaries = {
// Hero Section
"home.badge": "Now free for starters",
"home.hero.title": "Calm booking software for local services",
"home.hero.title": "Simple booking software for local services",
"home.hero.subtitle": "Manage bookings, customers, and your team in one place. No unnecessary complexity — just a reliable system that works.",
"home.hero.cta.primary": "Get started free",
"home.hero.cta.secondary": "Open booking page",
@@ -691,6 +716,10 @@ const dictionaries = {
"widget.type.floating.title": "Floating bubble",
"widget.type.floating.desc": "Floating button in the corner of the screen",
"widget.type.floating.preview": "Best for: E-commerce, continuous availability",
"widget.type.map.title": "Location map",
"widget.type.map.desc": "Embed a styled map for your real address.",
"widget.map.noLocation": "No location found. Paste a Google Maps/Mapy.cz link, coordinates, or full address.",
"widget.map.resolved": "Location resolved",
"widget.button.text": "Book appointment",
"widget.modal.trigger": "Open booking",
"widget.styling.title": "Appearance",
@@ -728,11 +757,32 @@ const dictionaries = {
"common.copy": "Copy",
"common.copied": "Copied!",
// Integration Modal
"integration.title": "Add Bookra to Your Website",
"integration.subtitle": "Choose how you want to integrate Bookra. Share a link or embed directly on your website.",
"integration.tab.hosted": "Your Link",
"integration.tab.embed": "Embed Widget",
"integration.hosted.title": "Your Booking Page",
"integration.hosted.desc": "Share this link with customers. They can book directly without any setup.",
"integration.hosted.hint": "Perfect for social media, email signatures, or direct sharing",
"integration.share.facebook": "Share on Facebook",
"integration.share.x": "Share on X",
"integration.embed.title": "Embed on Your Website",
"integration.embed.desc": "Add the booking widget directly to your website. Choose your platform:",
"integration.embed.html": "HTML/JS",
"integration.embed.react": "React",
"integration.embed.solid": "SolidJS",
"integration.embed.php": "PHP/WordPress",
"integration.help.title": "Need help with installation?",
"integration.help.desc": "Contact us at",
"integration.demo.title": "Booking Page",
"integration.demo.desc": "Customers book through your link. No installation needed.",
// Footer
"footer.copyright": "© 2026 Bookra. All rights reserved.",
"footer.privacy": "Privacy",
"footer.terms": "Terms",
"footer.description": "Calm booking software for local services. Manage bookings, customers, and your team in one place.",
"footer.description": "Simple booking software for local services. Manage bookings, customers, and your team in one place.",
"footer.links.title": "Navigation",
"footer.legal.title": "Legal",
+218 -24
View File
@@ -187,8 +187,15 @@ export function DashboardRoute() {
const i18n = useI18n();
const auth = useAuth();
const theme = useTheme();
const [searchParams] = useSearchParams();
const [activeSection, setActiveSection] = createSignal<Section>("overview");
const isDark = () => theme.resolvedTheme() === "dark";
const [searchParams, setSearchParams] = useSearchParams();
const initialTab = () => {
const t = searchParams["tab"];
const tab = Array.isArray(t) ? t[0] : t;
const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings", "analytics"];
return validTabs.includes(tab as Section) ? (tab as Section) : "overview";
};
const [activeSection, setActiveSection] = createSignal<Section>(initialTab());
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
const [isPageTransitioning, setIsPageTransitioning] = createSignal(false);
const [billingNotice, setBillingNotice] = createSignal<string | null>(null);
@@ -274,6 +281,7 @@ export function DashboardRoute() {
if (section === activeSection()) return;
setIsPageTransitioning(true);
setTimeout(() => { setActiveSection(section); setIsPageTransitioning(false); }, 150);
setSearchParams({ tab: section });
};
const openCheckout = async () => {
@@ -317,6 +325,16 @@ export function DashboardRoute() {
finally { setBillingAction(null); }
};
createEffect(() => {
const t = searchParams["tab"];
const tab = Array.isArray(t) ? t[0] : t;
const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings", "analytics"];
if (tab && validTabs.includes(tab as Section) && tab !== activeSection()) {
setIsPageTransitioning(true);
setTimeout(() => { setActiveSection(tab as Section); setIsPageTransitioning(false); }, 150);
}
});
createEffect(() => {
const state = Array.isArray(searchParams["billing"]) ? searchParams["billing"][0] : searchParams["billing"];
if (state && state !== handledBillingState()) {
@@ -332,6 +350,7 @@ export function DashboardRoute() {
{ id: "customers" as Section, label: i18n.t("dashboard.customers"), icon: UserCircleIcon },
{ id: "zones" as Section, label: i18n.t("dashboard.zones"), icon: MapPinIcon },
{ id: "billing" as Section, label: i18n.t("dashboard.billing"), icon: CreditCardIcon },
{ id: "analytics" as Section, label: i18n.locale() === "cs" ? "Analytika" : "Analytics", icon: BarChart3Icon },
{ id: "settings" as Section, label: i18n.t("dashboard.settings"), icon: Settings2Icon },
];
@@ -380,12 +399,16 @@ export function DashboardRoute() {
<aside class="hidden lg:flex flex-col w-64 h-screen bg-canvas border-r border-border sticky top-0 shrink-0">
<div class="p-6">
<A href="/" class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-colors">
<img src="/bookra-logo.svg" alt="Bookra" class="h-8 w-auto dark:invert" />
<div class="relative h-8">
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
</div>
</A>
</div>
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<button
<A
href={`?tab=${item.id}`}
onClick={() => changeSection(item.id)}
class={`
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
@@ -396,7 +419,7 @@ export function DashboardRoute() {
`}
>
<item.icon /> {item.label}
</button>
</A>
))}
</nav>
<div class="p-4 border-t border-border space-y-1">
@@ -432,14 +455,18 @@ export function DashboardRoute() {
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm transition-opacity" onClick={() => setIsMobileMenuOpen(false)} />
<div class="absolute left-0 top-0 h-full w-72 bg-canvas shadow-2xl">
<div class="p-4 border-b border-border flex items-center justify-between">
<img src="/bookra-logo.svg" alt="Bookra" class="h-8 w-auto dark:invert" />
<div class="relative h-8">
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
</div>
<button onClick={() => setIsMobileMenuOpen(false)} class="p-2 hover:bg-canvas-subtle rounded-xl transition-colors" aria-label={i18n.t("dashboard.close")}>
<XIcon />
</button>
</div>
<nav class="p-4 space-y-1">
{navItems.map((item) => (
<button
<A
href={`?tab=${item.id}`}
onClick={() => { changeSection(item.id); setIsMobileMenuOpen(false); }}
class={`
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all
@@ -447,7 +474,7 @@ export function DashboardRoute() {
`}
>
<item.icon /> {item.label}
</button>
</A>
))}
</nav>
<div class="p-4 border-t border-border">
@@ -565,7 +592,7 @@ export function DashboardRoute() {
</svg>
{i18n.t("dashboard.shareManage")}
</button>
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}</span>
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<NotificationDropdown bookings={normalizedAllBookings()} billing={resolvedBilling()} />
</div>
</div>
@@ -696,7 +723,7 @@ export function DashboardRoute() {
const [newBookingTime, setNewBookingTime] = createSignal("");
const [newBookingNotes, setNewBookingNotes] = createSignal("");
const resolvedAllBookings = () => (summary.latest as any)?.allBookings ?? demoData().summary.allBookings ?? [];
const resolvedAllBookings = () => (summary.latest as any)?.allBookings ?? (isDemoMode() ? demoData().summary.allBookings : []) ?? [];
const filteredBookings = createMemo(() => {
let bookings = resolvedAllBookings();
@@ -1251,17 +1278,18 @@ export function DashboardRoute() {
const [activeEmailType, setActiveEmailType] = createSignal("confirmation");
const [emailSubject, setEmailSubject] = createSignal("");
const [emailBody, setEmailBody] = createSignal("");
const [emailSaving, setEmailSaving] = createSignal(false);
const presetColors = [
{ name: i18n.locale() === "cs" ? "Hneda" : "Terracotta", color: "#c25e3a" },
{ name: i18n.locale() === "cs" ? "Zelena" : "Sage", color: "#5a7c5a" },
{ name: i18n.locale() === "cs" ? "Modra" : "Ocean", color: "#3a6a8a" },
{ name: i18n.locale() === "cs" ? "Fialova" : "Plum", color: "#6a4a7a" },
{ name: i18n.locale() === "cs" ? "Hnědá" : "Terracotta", color: "#c25e3a" },
{ name: i18n.locale() === "cs" ? "Zelená" : "Sage", color: "#5a7c5a" },
{ name: i18n.locale() === "cs" ? "Modrá" : "Ocean", color: "#3a6a8a" },
{ name: i18n.locale() === "cs" ? "Fialová" : "Plum", color: "#6a4a7a" },
];
const handleSaveBrand = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo rezimu nelze ukladat." : "Cannot save in demo mode."); return; }
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setBrandSaving(true);
try {
await (apiClient as any).PUT("/v1/tenants/brand", { headers: { Authorization: `Bearer ${bearer}` }, body: { primaryColor: brandColor() } });
@@ -1273,7 +1301,7 @@ export function DashboardRoute() {
<div class={`space-y-6 transition-opacity duration-200 ${isPageTransitioning() ? "opacity-0" : "opacity-100"}`}>
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{i18n.t("dashboard.settings")}</h1>
<p class="text-ink-muted mt-1">{i18n.locale() === "cs" ? "Spravujte svuj podnik, branding a predvolby." : "Manage your business, branding and preferences."}</p>
<p class="text-ink-muted mt-1">{i18n.locale() === "cs" ? "Spravujte svůj podnik, branding a předvolby." : "Manage your business, branding and preferences."}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -1304,7 +1332,7 @@ export function DashboardRoute() {
<div class="w-10 h-10 rounded-full flex items-center justify-center text-canvas font-bold" style={{ background: brandColor() }}>B</div>
<div>
<p class="font-semibold text-ink">Bookra</p>
<p class="text-xs text-ink-muted">{i18n.locale() === "cs" ? "Rezervujte si termin" : "Book an appointment"}</p>
<p class="text-xs text-ink-muted">{i18n.locale() === "cs" ? "Rezervujte si termín" : "Book an appointment"}</p>
</div>
</div>
<button class="w-full py-2.5 rounded-lg text-canvas font-medium text-sm" style={{ background: brandColor() }}>{i18n.locale() === "cs" ? "Rezervovat" : "Book Now"}</button>
@@ -1321,7 +1349,7 @@ export function DashboardRoute() {
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><MailIcon /></div>
<div>
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.settings.emailNotifications")}</h3>
<p class="text-sm text-ink-muted">{i18n.locale() === "cs" ? "Upravte emaily posilane zakaznikum." : "Customize emails sent to customers."}</p>
<p class="text-sm text-ink-muted">{i18n.locale() === "cs" ? "Upravte e-maily posílané zákazníkům." : "Customize emails sent to customers."}</p>
</div>
</div>
<div class="flex gap-2 mb-4 overflow-x-auto pb-2">
@@ -1337,10 +1365,22 @@ export function DashboardRoute() {
))}
</div>
<div class="space-y-4">
<Input type="text" label={i18n.t("dashboard.settings.emailSubject")} value={emailSubject()} onInput={(e) => setEmailSubject(e.currentTarget.value)} placeholder={activeEmailType() === "confirmation" ? (i18n.locale() === "cs" ? "Potvrzeni rezervace" : "Booking Confirmation") : ""} />
<Input type="text" label={i18n.t("dashboard.settings.emailSubject")} value={emailSubject()} onInput={(e) => setEmailSubject(e.currentTarget.value)} placeholder={activeEmailType() === "confirmation" ? (i18n.locale() === "cs" ? "Potvrzení rezervace" : "Booking Confirmation") : ""} />
<Textarea label={i18n.t("dashboard.settings.emailBody")} value={emailBody()} onInput={(e) => setEmailBody(e.currentTarget.value)} rows={6} resize="none" placeholder={i18n.locale() === "cs" ? "Obsah emailu..." : "Email body..."} />
</div>
<button class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium">{i18n.t("dashboard.settings.save")}</button>
<button onClick={async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setEmailSaving(true);
try {
await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody() } });
setDemoNotice(i18n.locale() === "cs" ? "Šablona uložena." : "Template saved.");
} catch { setDemoNotice(i18n.locale() === "cs" ? "Chyba při ukládání." : "Save failed."); }
finally { setEmailSaving(false); }
}} disabled={emailSaving()} class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium disabled:opacity-60 flex items-center justify-center gap-2">
<Show when={emailSaving()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
{emailSaving() ? i18n.t("dashboard.saving") : i18n.t("dashboard.settings.save")}
</button>
</div>
</div>
@@ -1355,6 +1395,156 @@ export function DashboardRoute() {
);
};
// ==========================================
// ANALYTICS PAGE
// ==========================================
const AnalyticsPage = () => {
const cs = i18n.locale() === "cs";
const bookingData = normalizedAllBookings();
const totalBookings = bookingData.length;
const confirmed = bookingData.filter((b: { status: string }) => b.status === "confirmed").length;
const completed = bookingData.filter((b: { status: string }) => b.status === "completed").length;
const cancelled = bookingData.filter((b: { status: string }) => b.status === "cancelled").length;
const pending = bookingData.filter((b: { status: string }) => b.status === "pending").length;
const occupancyRate = totalBookings > 0 ? Math.round(((confirmed + completed) / totalBookings) * 100) : 0;
const kpiCards = [
{ label: cs ? "Celkem rezervací" : "Total bookings", value: totalBookings, change: "+12 %", trend: "up" as const, icon: CalendarDaysIcon },
{ label: cs ? "Potvrzeno" : "Confirmed", value: confirmed, change: "+8 %", trend: "up" as const, icon: CheckCircleIcon },
{ label: cs ? "Obsazenost" : "Occupancy", value: `${occupancyRate}%`, change: occupancyRate > 80 ? "+5 %" : "-3 %", trend: occupancyRate > 80 ? "up" as const : "down" as const, icon: TrendingUpIcon },
{ label: cs ? "Zrušeno" : "Cancelled", value: cancelled, change: "-2 %", trend: "down" as const, icon: XCircleIcon },
];
const weeklyData = [
{ day: cs ? "Po" : "Mon", bookings: 12, revenue: 2400 },
{ day: cs ? "Út" : "Tue", bookings: 18, revenue: 3600 },
{ day: cs ? "St" : "Wed", bookings: 14, revenue: 2800 },
{ day: cs ? "Čt" : "Thu", bookings: 22, revenue: 4400 },
{ day: cs ? "Pá" : "Fri", bookings: 28, revenue: 5600 },
{ day: cs ? "So" : "Sat", bookings: 16, revenue: 3200 },
{ day: cs ? "Ne" : "Sun", bookings: 8, revenue: 1600 },
];
const maxRevenue = Math.max(...weeklyData.map((d) => d.revenue));
const statusBreakdown = [
{ label: cs ? "Potvrzeno" : "Confirmed", count: confirmed, color: "bg-accent" },
{ label: cs ? "Dokončeno" : "Completed", count: completed, color: "bg-success" },
{ label: cs ? "Čeká" : "Pending", count: pending, color: "bg-warning" },
{ label: cs ? "Zrušeno" : "Cancelled", count: cancelled, color: "bg-error" },
];
return (
<div class={`space-y-6 transition-opacity duration-200 ${isPageTransitioning() ? "opacity-0" : "opacity-100"}`}>
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{cs ? "Analytika a reporty" : "Analytics & Reports"}</h1>
<p class="text-ink-muted mt-1">{cs ? "Podrobné statistiky, exporty a přehledy pro váš podnik." : "Detailed statistics, exports, and business insights."}</p>
</div>
{/* KPI Cards */}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{kpiCards.map((kpi) => (
<div class="surface-card p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-sm text-ink-muted">{kpi.label}</span>
<span class="w-8 h-8 rounded-lg bg-accent-subtle/50 flex items-center justify-center text-accent"><kpi.icon /></span>
</div>
<p class="text-2xl font-bold text-ink font-display">{kpi.value}</p>
<div class="flex items-center gap-1 mt-1">
<span class={`text-xs font-medium ${kpi.trend === "up" ? "text-success" : "text-error"}`}>{kpi.change}</span>
<span class="text-xs text-ink-subtle">{cs ? "vs minulý měsíc" : "vs last month"}</span>
</div>
</div>
))}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Weekly Revenue Chart */}
<div class="lg:col-span-2 surface-card p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">{cs ? "Týdenní přehled" : "Weekly Overview"}</h3>
<p class="text-sm text-ink-muted">{cs ? "Rezervace a tržby za posledních 7 dní" : "Bookings and revenue over the last 7 days"}</p>
</div>
<div class="flex items-center gap-4 text-sm">
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-accent" />
<span class="text-ink-muted">{cs ? "Rezervace" : "Bookings"}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-accent/30" />
<span class="text-ink-muted">{cs ? "Tržby (Kč)" : "Revenue ($)"}</span>
</div>
</div>
</div>
<div class="flex items-end gap-3 h-48">
{weeklyData.map((d) => (
<div class="flex-1 flex flex-col items-center gap-2">
<div class="w-full flex items-end gap-1 h-36">
<div class="flex-1 rounded-t-md bg-accent/20" style={{ height: `${(d.bookings / 30) * 100}%` }} />
<div class="flex-1 rounded-t-md bg-accent" style={{ height: `${(d.revenue / maxRevenue) * 100}%` }} />
</div>
<span class="text-xs text-ink-subtle">{d.day}</span>
</div>
))}
</div>
</div>
{/* Status Breakdown */}
<div class="surface-card p-6">
<h3 class="text-lg font-semibold text-ink mb-1">{cs ? "Stav rezervací" : "Booking Status"}</h3>
<p class="text-sm text-ink-muted mb-6">{cs ? "Rozdělení podle stavu" : "Breakdown by status"}</p>
<div class="space-y-4">
{statusBreakdown.map((s) => (
<div>
<div class="flex items-center justify-between mb-1.5">
<span class="text-sm text-ink">{s.label}</span>
<span class="text-sm font-medium text-ink">{s.count}</span>
</div>
<div class="h-2 rounded-full bg-canvas-subtle overflow-hidden">
<div class={`h-full rounded-full ${s.color} transition-all duration-500`} style={{ width: totalBookings > 0 ? `${(s.count / totalBookings) * 100}%` : "0%" }} />
</div>
</div>
))}
</div>
<div class="mt-6 pt-6 border-t border-border/60">
<div class="flex items-center justify-between">
<span class="text-sm text-ink-muted">{cs ? "Konverzní poměr" : "Conversion rate"}</span>
<span class="text-lg font-bold text-ink font-display">{totalBookings > 0 ? Math.round(((confirmed + completed) / totalBookings) * 100) : 0}%</span>
</div>
</div>
</div>
</div>
{/* Top Services */}
<div class="surface-card p-6">
<h3 class="text-lg font-semibold text-ink mb-4">{cs ? "Nejoblíbenější služby" : "Top Services"}</h3>
<div class="space-y-3">
{[
{ name: cs ? "Masáž" : "Massage", bookings: 45, revenue: 9000 },
{ name: cs ? "Kosmetika" : "Cosmetics", bookings: 32, revenue: 6400 },
{ name: cs ? "Fyzioterapie" : "Physiotherapy", bookings: 28, revenue: 8400 },
{ name: cs ? "Manikúra" : "Manicure", bookings: 24, revenue: 3600 },
].map((svc, i) => (
<div class="flex items-center gap-4">
<span class="w-6 h-6 rounded-full bg-canvas-subtle flex items-center justify-center text-xs font-medium text-ink-subtle">{i + 1}</span>
<div class="flex-1">
<p class="text-sm font-medium text-ink">{svc.name}</p>
<div class="h-1.5 rounded-full bg-canvas-subtle mt-1.5 overflow-hidden">
<div class="h-full rounded-full bg-accent" style={{ width: `${(svc.bookings / 45) * 100}%` }} />
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium text-ink">{svc.bookings}</p>
<p class="text-xs text-ink-subtle">{cs ? `${svc.revenue.toLocaleString()}` : `$${svc.revenue.toLocaleString()}`}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
};
// ==========================================
// ACTIVE PAGE SWITCHER
// ==========================================
@@ -1365,6 +1555,7 @@ export function DashboardRoute() {
case "customers": return <CustomersPage />;
case "zones": return <ZonesPage />;
case "billing": return <BillingPage />;
case "analytics": return <AnalyticsPage />;
case "settings": return <SettingsPage />;
default: return <OverviewPage />;
}
@@ -1379,7 +1570,10 @@ export function DashboardRoute() {
<div class="flex-1 flex flex-col min-h-screen">
{/* Mobile Header */}
<div class="lg:hidden bg-canvas/80 backdrop-blur-xl border-b border-border p-4 flex items-center justify-between sticky top-0 z-40">
<img src="/bookra-logo.svg" alt="Bookra" class="h-8 w-auto dark:invert" />
<div class="relative h-8">
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
</div>
<div class="flex items-center gap-2">
<NotificationDropdown bookings={normalizedAllBookings()} billing={resolvedBilling()} />
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={i18n.locale() === "cs" ? "Otevrit menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
@@ -1410,7 +1604,7 @@ export function DashboardRoute() {
<div class="absolute inset-0 bg-accent/20 rounded-full blur-3xl opacity-60" />
<BookraCharacter pose="hello" size="xl" animate={true} />
</div>
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{i18n.locale() === "cs" ? "Vas rezervacni system ceka" : "Your booking system awaits"}</h2>
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{i18n.locale() === "cs" ? "Váš rezervační systém čeká" : "Your booking system awaits"}</h2>
<p class="text-ink-muted mt-3 text-lg max-w-md mx-auto">{i18n.t("dashboard.welcome.body")}</p>
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "sign-in" } }))} class="btn-primary w-full sm:w-auto">
@@ -1421,7 +1615,7 @@ export function DashboardRoute() {
<SparklesIcon /> {i18n.t("dashboard.tryDemo")}
</button>
</div>
<p class="mt-6 text-sm text-ink-muted">{i18n.locale() === "cs" ? "Jeste nemate ucet? " : "No account yet? "}
<p class="mt-6 text-sm text-ink-muted">{i18n.locale() === "cs" ? "Ještě nemáte účet? " : "No account yet? "}
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))} class="text-accent hover:text-accent-hover font-medium underline underline-offset-2">{i18n.t("auth.createAccount")}</button>
</p>
</div>
@@ -1431,7 +1625,7 @@ export function DashboardRoute() {
<Show when={token() && (bootstrap.loading || summary.loading || billing.loading)}>
<div class="flex flex-col items-center justify-center py-20">
<BookraCharacter pose="walk" size="lg" animate={true} />
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{i18n.locale() === "cs" ? "Nacitani dashboardu..." : "Loading your dashboard..."}</p>
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{i18n.locale() === "cs" ? "Načítání dashboardu..." : "Loading your dashboard..."}</p>
</div>
</Show>
@@ -0,0 +1,101 @@
import { Show, createMemo } from "solid-js";
import { SparklesIcon, XIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function BillingPage() {
const data = useDashboardData();
const current = data.resolvedBilling();
const entitlements = current?.entitlements;
const isTrialing = current?.subscriptionStatus === "trialing";
const isActive = current?.subscriptionStatus === "active";
const planCode = current?.planCode ?? "starter";
const planName = current?.planName ?? "Starter";
const trialDays = current?.trialDaysRemaining ?? 0;
const planFeatures = createMemo(() => {
const cs = data.i18n.locale() === "cs";
const base = [
{ label: data.i18n.t("dashboard.billing.locations"), value: entitlements?.maxLocations ?? 1, limit: entitlements?.maxLocations ?? 1 },
{ label: data.i18n.t("dashboard.billing.bookings"), value: entitlements?.maxBookings === -1 ? "\u221e" : (entitlements?.maxBookings ?? 50), limit: entitlements?.maxBookings ?? 50 },
{ label: data.i18n.t("dashboard.billing.staff"), value: entitlements?.maxStaff ?? 1, limit: entitlements?.maxStaff ?? 1 },
];
if (planCode === "pro" || planCode === "business") {
base.push({ label: cs ? "Emailove pripominky" : "Email reminders", value: "\u2713", limit: "\u2713" });
base.push({ label: cs ? "Analytika" : "Analytics", value: "\u2713", limit: "\u2713" });
}
if (planCode === "business") {
base.push({ label: cs ? "API pristup" : "API access", value: "\u2713", limit: "\u2713" });
}
return base;
});
return (
<div class="space-y-6">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.billing")}</h1>
<p class="text-ink-muted mt-1">{data.i18n.t("dashboard.billing.currentPlan")}: {planName}</p>
</div>
{/* Billing notice/error */}
<Show when={data.billingNotice()}>
<div class="p-4 rounded-xl bg-accent-subtle border border-accent/20 flex items-center justify-between animate-fade-in">
<div class="flex items-center gap-3"><SparklesIcon /><p class="text-sm font-medium text-accent">{data.billingNotice()}</p></div>
<button onClick={() => data.setBillingNotice(null)} class="p-1.5 hover:bg-accent/10 rounded-lg text-accent"><XIcon /></button>
</div>
</Show>
<Show when={data.billingError()}>
<div class="p-4 rounded-xl bg-canvas-subtle border border-ink/10 flex items-center justify-between animate-fade-in">
<p class="text-sm font-medium text-ink">{data.billingError()}</p>
<button onClick={() => data.setBillingError(null)} class="p-1.5 hover:bg-canvas-muted rounded-lg text-ink-muted"><XIcon /></button>
</div>
</Show>
{/* Current Plan Card */}
<div class="surface-card p-6">
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold text-ink font-display">{planName}</h3>
{isTrialing && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.locale() === "cs" ? `Zkusebni doba (${trialDays} dnu)` : `Trial (${trialDays} days)`}</span>}
{isActive && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.confirmed")}</span>}
</div>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.billing.period")}: {data.billingInterval() === "monthly" ? (data.i18n.locale() === "cs" ? "Mesicne" : "Monthly") : (data.i18n.locale() === "cs" ? "Rocne" : "Yearly")}</p>
</div>
<div class="flex gap-2 shrink-0">
<button onClick={() => data.setBillingInterval("monthly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "monthly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Mesicne" : "Monthly"}</button>
<button onClick={() => data.setBillingInterval("yearly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "yearly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Rocne" : "Yearly"}</button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{planFeatures().map((feature: any) => (
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{feature.label}</p>
<p class="text-lg font-semibold text-ink">{typeof feature.value === "number" ? `${feature.value} / ${feature.limit}` : feature.value}</p>
</div>
))}
</div>
<div class="flex flex-wrap gap-3 mt-6">
<button onClick={() => void data.openCheckout()} disabled={data.billingAction() === "checkout"} class="btn-primary disabled:opacity-50">
{data.billingAction() === "checkout" ? (data.i18n.locale() === "cs" ? "Otevirani..." : "Opening...") : (isTrialing ? data.i18n.t("dashboard.upgrade") : data.i18n.t("dashboard.checkout"))}
</button>
<Show when={isActive || isTrialing}>
<button onClick={() => void data.openPortal()} class="btn-secondary">{data.i18n.locale() === "cs" ? "Spravovat predplatne" : "Manage subscription"}</button>
</Show>
<button onClick={() => void data.refreshBilling()} disabled={data.billingAction() === "refresh"} class="btn-secondary disabled:opacity-50">
{data.billingAction() === "refresh" ? (data.i18n.locale() === "cs" ? "Obnovovani..." : "Refreshing...") : data.i18n.t("dashboard.refreshBilling")}
</button>
</div>
</div>
</div>
);
}
export default function BillingRoute() {
return (
<DashboardLayout>
<BillingPage />
</DashboardLayout>
);
}
@@ -0,0 +1,252 @@
import { Show, createSignal, createMemo } from "solid-js";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Textarea } from "../../components/ui/textarea";
import { getInitials } from "../../components/dashboard/types";
import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons";
import { apiClient } from "../../lib/api-client";
import { DashboardLayout, useDashboardData } from "./layout";
function BookingsPage() {
const data = useDashboardData();
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
const [searchQuery, setSearchQuery] = createSignal("");
const [showNewBooking, setShowNewBooking] = createSignal(false);
const [creatingBooking, setCreatingBooking] = createSignal(false);
const [pinnedBookingIds, setPinnedBookingIds] = createSignal<Set<string>>(new Set());
const [newBookingCustomer, setNewBookingCustomer] = createSignal("");
const [newBookingEmail, setNewBookingEmail] = createSignal("");
const [newBookingService, setNewBookingService] = createSignal("");
const [newBookingDate, setNewBookingDate] = createSignal("");
const [newBookingTime, setNewBookingTime] = createSignal("");
const [newBookingNotes, setNewBookingNotes] = createSignal("");
const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? [];
const filteredBookings = createMemo(() => {
let bookings = resolvedAllBookings();
if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus());
const now = new Date();
if (filterDateRange() === "today") bookings = bookings.filter((b: any) => new Date(b.startsAt).toDateString() === now.toDateString());
else if (filterDateRange() === "week") {
const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= weekEnd; });
} else if (filterDateRange() === "month") {
const monthEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= monthEnd; });
}
if (searchQuery().trim()) {
const q = searchQuery().toLowerCase();
bookings = bookings.filter((b: any) =>
b.customerName?.toLowerCase().includes(q) ||
b.service?.toLowerCase().includes(q) ||
b.reference?.toLowerCase().includes(q)
);
}
return bookings.sort((a: any, b: any) => {
const aPinned = pinnedBookingIds().has(a.id) ? 1 : 0;
const bPinned = pinnedBookingIds().has(b.id) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
return new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime();
});
});
const bookingStats = createMemo(() => {
const bookings = resolvedAllBookings();
return {
total: bookings.length,
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
pending: bookings.filter((b: any) => b.status === "pending").length,
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
completed: bookings.filter((b: any) => b.status === "completed").length,
};
});
const handleCreateBooking = async () => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo rezimu nelze vytvaret rezervace." : "Cannot create bookings in demo mode.");
setShowNewBooking(false); return;
}
setCreatingBooking(true);
try {
await (apiClient as any).POST("/v1/catalog/bookings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
customerName: newBookingCustomer(), customerEmail: newBookingEmail(), service: newBookingService(),
startsAt: new Date(`${newBookingDate()}T${newBookingTime()}`).toISOString(), notes: newBookingNotes()
}
});
setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes("");
setShowNewBooking(false);
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoreni rezervace selhalo." : "Failed to create booking."); }
finally { setCreatingBooking(false); }
};
const statusLabels: Record<string, string> = {
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.bookingManagement")}</h1>
<p class="text-ink-muted mt-1">{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}</p>
</div>
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm shrink-0">
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
</button>
</div>
{/* Stats */}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
{ key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending },
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
].map((s) => (
<button onClick={() => setFilterStatus(s.key as any)} class={`surface-card p-4 text-left transition-all hover:shadow-md ${filterStatus() === s.key ? "ring-2 ring-accent" : ""}`}>
<p class="text-xs text-ink-muted uppercase tracking-wider">{s.label}</p>
<p class="text-2xl font-bold text-ink mt-1 font-display">{s.value}</p>
</button>
))}
</div>
{/* Filters */}
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<Input type="text" placeholder={data.i18n.t("dashboard.search.bookings")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
</div>
<div class="flex gap-2">
<Select value={filterStatus()} onChange={(v) => setFilterStatus(v as any)} options={[
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
{ value: "pending", label: data.i18n.t("dashboard.pending") },
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
{ value: "completed", label: data.i18n.t("dashboard.completed") },
]} />
<Select value={filterDateRange()} onChange={(v) => setFilterDateRange(v as any)} options={[
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "today", label: data.i18n.t("dashboard.filter.today") },
{ value: "week", label: data.i18n.t("dashboard.filter.week") },
{ value: "month", label: data.i18n.t("dashboard.filter.month") },
]} />
</div>
</div>
{/* Bookings List */}
<div class="surface-card overflow-hidden">
<Show when={filteredBookings().length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4">
<CalendarDaysIcon />
</div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.empty.bookingsDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
{filteredBookings().map((booking: any) => {
const isPinned = () => pinnedBookingIds().has(booking.id);
const togglePin = (e: Event) => {
e.stopPropagation();
setPinnedBookingIds((prev) => {
const next = new Set(prev);
if (next.has(booking.id)) next.delete(booking.id);
else next.add(booking.id);
return next;
});
};
return (
<div class={`flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors group ${isPinned() ? "bg-accent-soft/40" : ""}`}>
<div class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
booking.status === "confirmed" ? "bg-accent text-canvas" : "bg-canvas-muted text-ink-muted"
}`}>
{getInitials(booking.customerName)}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="font-medium text-ink">{booking.customerName}</p>
{isPinned() && <span class="w-1.5 h-1.5 rounded-full bg-accent" />}
</div>
<p class="text-sm text-ink-muted">{booking.service} &bull; {new Date(booking.startsAt).toLocaleDateString()} {new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={togglePin}
class={`flex h-6 w-6 items-center justify-center rounded-full transition-all duration-200 ${
isPinned()
? "bg-accent text-canvas hover:bg-accent-hover"
: "bg-canvas-subtle border border-border text-ink-subtle hover:border-accent/40 hover:text-accent"
}`}
aria-label={isPinned() ? "Unpin" : "Pin"}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="4" class={isPinned() ? "" : "opacity-0"} />
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" stroke-width="1" class={isPinned() ? "opacity-0" : ""} />
</svg>
</button>
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
{statusLabels[booking.status]}
</span>
<button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}>
<EyeIcon />
</button>
</div>
</div>
);
})}
</div>
</Show>
</div>
{/* New Booking Modal */}
<Show when={showNewBooking()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowNewBooking(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{data.i18n.t("dashboard.newBooking")}</h3>
<button onClick={() => setShowNewBooking(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"><XIcon /></button>
</div>
<div class="p-6 space-y-4">
<Input type="text" label={data.i18n.t("booking.customer.name")} value={newBookingCustomer()} onInput={(e) => setNewBookingCustomer(e.currentTarget.value)} />
<Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} />
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
<div class="grid grid-cols-2 gap-4">
<Input type="date" label={data.i18n.t("dashboard.bookingModal.dateTime")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
<Input type="time" label={data.i18n.t("dashboard.bookingModal.duration")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />
</div>
<Textarea label={data.i18n.t("booking.customer.notes")} value={newBookingNotes()} onInput={(e) => setNewBookingNotes(e.currentTarget.value)} rows={3} resize="none" />
</div>
<div class="p-6 border-t border-border flex gap-3">
<button onClick={() => setShowNewBooking(false)} class="flex-1 px-4 py-2.5 border border-border rounded-xl text-ink hover:bg-canvas-subtle transition-colors">{data.i18n.t("common.cancel")}</button>
<button onClick={handleCreateBooking} disabled={creatingBooking() || !newBookingCustomer() || !newBookingEmail() || !newBookingDate() || !newBookingTime()} class="flex-1 px-4 py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center justify-center gap-2">
<Show when={creatingBooking()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
{creatingBooking() ? data.i18n.t("dashboard.creating") : data.i18n.t("dashboard.createBooking")}
</button>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function BookingsRoute() {
return (
<DashboardLayout>
<BookingsPage />
</DashboardLayout>
);
}
@@ -0,0 +1,178 @@
import { Show, createSignal, createMemo } from "solid-js";
import { Input } from "../../components/ui/input";
import { getInitials } from "../../components/dashboard/types";
import { UsersIcon, XIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function CustomersPage() {
const data = useDashboardData();
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedCustomer, setSelectedCustomer] = createSignal<any>(null);
const [showCustomerDetail, setShowCustomerDetail] = createSignal(false);
const [pinnedCustomerIds, setPinnedCustomerIds] = createSignal<Set<string>>(new Set());
const demoCustomers = [
{ id: "1", name: "Martina Novakova", email: "martina@example.com", phone: "+420 123 456 789", bookingsCount: 5, lastBookingAt: new Date(Date.now() - 86400000).toISOString(), status: "active", notes: "Alergie na silne vune" },
{ id: "2", name: "David Svoboda", email: "david@example.com", phone: "+420 987 654 321", bookingsCount: 3, lastBookingAt: new Date(Date.now() - 172800000).toISOString(), status: "active", notes: "" },
{ id: "3", name: "Jana KovarovA", email: "jana@example.com", phone: "+420 555 666 777", bookingsCount: 8, lastBookingAt: new Date(Date.now() - 259200000).toISOString(), status: "vip", notes: "Uprednostnuje ranni terminy" },
{ id: "4", name: "Alice Johnson", email: "alice@example.com", phone: "+420 111 222 333", bookingsCount: 1, lastBookingAt: new Date(Date.now() - 604800000).toISOString(), status: "inactive", notes: "" },
{ id: "5", name: "Bob Smith", email: "bob@example.com", phone: "+420 444 555 666", bookingsCount: 12, lastBookingAt: new Date(Date.now() - 432000000).toISOString(), status: "vip", notes: "Loyal customer" },
];
const resolvedCustomers = () => demoCustomers;
const resolvedBookings = () => data.normalizedAllBookings();
const filteredCustomers = createMemo(() => {
let result = resolvedCustomers();
if (searchQuery().trim()) {
const q = searchQuery().toLowerCase();
result = result.filter((c: any) => c.name?.toLowerCase().includes(q) || c.email?.toLowerCase().includes(q) || c.phone?.toLowerCase().includes(q));
}
return result.sort((a: any, b: any) => {
const aPinned = pinnedCustomerIds().has(a.id) ? 1 : 0;
const bPinned = pinnedCustomerIds().has(b.id) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
return new Date(b.lastBookingAt || 0).getTime() - new Date(a.lastBookingAt || 0).getTime();
});
});
const getCustomerBookings = (email: string) => {
return resolvedBookings().filter((b: any) => b.customerEmail?.toLowerCase() === email?.toLowerCase()).sort((a: any, b: any) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime());
};
const statusBadge = (status: string) => {
switch (status) {
case "active": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.filter.all")}</span>;
case "vip": return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-ink/10 text-ink">VIP</span>;
default: return <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-canvas-subtle text-ink-subtle">{data.i18n.t("dashboard.customer.status")}</span>;
}
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.customers")}</h1>
<p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zakazniku" : "customers"}</p>
</div>
</div>
<div class="surface-card p-4">
<Input type="text" placeholder={data.i18n.t("dashboard.search.customers")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
</div>
<div class="surface-card overflow-hidden">
<Show when={filteredCustomers().length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><UsersIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.empty.customersDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
{filteredCustomers().map((customer: any) => {
const isPinned = () => pinnedCustomerIds().has(customer.id);
const togglePin = (e: Event) => {
e.stopPropagation();
setPinnedCustomerIds((prev) => {
const next = new Set(prev);
if (next.has(customer.id)) next.delete(customer.id);
else next.add(customer.id);
return next;
});
};
return (
<button onClick={() => { setSelectedCustomer(customer); setShowCustomerDetail(true); }} class={`w-full text-left flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors ${isPinned() ? "bg-accent-soft/40" : ""}`}>
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">{getInitials(customer.name)}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="font-medium text-ink">{customer.name}</p>
{statusBadge(customer.status)}
{isPinned() && (
<span class="w-1.5 h-1.5 rounded-full bg-accent" />
)}
</div>
<p class="text-sm text-ink-muted">{customer.email}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<button
type="button"
onClick={togglePin}
class={`flex h-6 w-6 items-center justify-center rounded-full transition-all duration-200 ${
isPinned()
? "bg-accent text-canvas hover:bg-accent-hover"
: "bg-canvas-subtle border border-border text-ink-subtle hover:border-accent/40 hover:text-accent"
}`}
aria-label={isPinned() ? "Unpin" : "Pin"}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="4" class={isPinned() ? "" : "opacity-0"} />
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" stroke-width="1" class={isPinned() ? "opacity-0" : ""} />
</svg>
</button>
<div class="text-right hidden sm:block">
<p class="text-sm font-medium text-ink">{customer.bookingsCount}</p>
<p class="text-xs text-ink-muted">{data.i18n.t("dashboard.customer.totalBookings")}</p>
</div>
</div>
</button>
);
})}
</div>
</Show>
</div>
{/* Customer Detail Modal */}
<Show when={showCustomerDetail() && selectedCustomer()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowCustomerDetail(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto animate-scale-in">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{data.i18n.t("dashboard.customerDetails")}</h3>
<button onClick={() => setShowCustomerDetail(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"><XIcon /></button>
</div>
<div class="p-6 space-y-5">
<div class="flex items-center gap-4 p-4 bg-canvas-subtle rounded-xl">
<div class="w-14 h-14 rounded-full bg-accent-subtle flex items-center justify-center text-lg font-bold text-accent">{getInitials(selectedCustomer().name)}</div>
<div>
<p class="font-semibold text-ink text-lg">{selectedCustomer().name}</p>
<p class="text-ink-muted">{selectedCustomer().email}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-canvas-subtle rounded-xl"><p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.customer.phone")}</p><p class="font-medium text-ink">{selectedCustomer().phone}</p></div>
<div class="p-4 bg-canvas-subtle rounded-xl"><p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.customer.totalBookings")}</p><p class="font-medium text-ink">{selectedCustomer().bookingsCount}</p></div>
</div>
<Show when={selectedCustomer().notes}>
<div class="p-4 bg-canvas-subtle rounded-xl"><p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.customer.notes")}</p><p class="text-ink text-sm">{selectedCustomer().notes}</p></div>
</Show>
<div>
<p class="text-sm font-semibold text-ink mb-3">{data.i18n.t("dashboard.customer.bookings")}</p>
<div class="space-y-2">
{getCustomerBookings(selectedCustomer().email).slice(0, 5).map((b: any) => (
<div class="flex items-center gap-3 p-3 rounded-xl border border-border/60">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink">{b.service}</p>
<p class="text-xs text-ink-muted">{new Date(b.startsAt).toLocaleDateString()}</p>
</div>
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${b.status === "confirmed" ? "bg-accent/10 text-accent" : "bg-canvas-muted text-ink-muted"}`}>{data.i18n.t(`dashboard.${b.status}`)}</span>
</div>
))}
<Show when={getCustomerBookings(selectedCustomer().email).length === 0}>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.customer.noBookings")}</p>
</Show>
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function CustomersRoute() {
return (
<DashboardLayout>
<CustomersPage />
</DashboardLayout>
);
}
@@ -0,0 +1,293 @@
import { createResource, createMemo, createSignal, useContext, createContext } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { useAuth } from "../../providers/auth-provider";
import { useI18n } from "../../providers/i18n-provider";
import { getInitials, getBookingDuration } from "../../components/dashboard/types";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
function createDemoData(i18n: ReturnType<typeof useI18n>) {
const cs = i18n.locale() === "cs";
const now = new Date();
const fmt = (d: Date) => d.toISOString();
const addDays = (n: number) => { const d = new Date(now); d.setDate(d.getDate() + n); return d; };
const addHours = (d: Date, h: number) => { const r = new Date(d); r.setHours(r.getHours() + h); return r; };
const services = cs
? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"]
: ["Massage", "Cosmetics", "Physiotherapy", "Manicure"];
const customers = [
{ name: cs ? "Martina Novakova" : "Martina Novakova", email: "martina@example.com" },
{ name: cs ? "David Svoboda" : "David Svoboda", email: "david@example.com" },
{ name: cs ? "Jana KovarovA" : "Jana KovarovA", email: "jana@example.com" },
{ name: cs ? "Alice Johnson" : "Alice Johnson", email: "alice@example.com" },
{ name: cs ? "Bob Smith" : "Bob Smith", email: "bob@example.com" },
];
const allBookings = [
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(addHours(addDays(0), 10)), endsAt: fmt(addHours(addDays(0), 11)), reference: "BOK-001", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(addHours(addDays(1), 14)), endsAt: fmt(addHours(addDays(1), 15)), reference: "BOK-002", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
{ id: "b3", customerName: customers[2].name, customerEmail: customers[2].email, service: services[2], status: "confirmed", startsAt: fmt(addHours(addDays(2), 9)), endsAt: fmt(addHours(addDays(2), 10)), reference: "BOK-003", location: cs ? "Studio B" : "Studio B", staffName: "", notes: cs ? "Bolest zad" : "Back pain" },
{ id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(addHours(addDays(-1), 11)), endsAt: fmt(addHours(addDays(-1), 12)), reference: "BOK-004", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
{ id: "b5", customerName: customers[4].name, customerEmail: customers[4].email, service: services[0], status: "completed", startsAt: fmt(addHours(addDays(-2), 13)), endsAt: fmt(addHours(addDays(-2), 14)), reference: "BOK-005", location: cs ? "Studio B" : "Studio B", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(addHours(addDays(3), 10)), endsAt: fmt(addHours(addDays(3), 11)), reference: "BOK-006", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
{ id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(addHours(addDays(0), 16)), endsAt: fmt(addHours(addDays(0), 17)), reference: "BOK-007", location: cs ? "Studio B" : "Studio B", staffName: "", notes: "" },
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(addHours(addDays(4), 9)), endsAt: fmt(addHours(addDays(4), 10)), reference: "BOK-008", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Lucie K." : "Lucie K.", notes: "" },
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(addHours(addDays(5), 11)), endsAt: fmt(addHours(addDays(5), 12)), reference: "BOK-009", location: cs ? "Hlavní salon" : "Main Salon", staffName: cs ? "Petra M." : "Petra M.", notes: "" },
{ id: "b10", customerName: customers[4].name, customerEmail: customers[4].email, service: services[1], status: "completed", startsAt: fmt(addHours(addDays(-3), 10)), endsAt: fmt(addHours(addDays(-3), 11)), reference: "BOK-010", location: cs ? "Studio B" : "Studio B", staffName: "", notes: "" },
];
return {
summary: {
tenantName: cs ? "Demo Studio" : "Demo Studio",
tenantSlug: "demo-studio",
publicBookingUrl: "http://localhost:3000/book/demo-studio",
kpis: [
{ label: i18n.t("dashboard.kpi.bookings"), value: 42, change: "+12%", trend: "up" },
{ label: i18n.t("dashboard.kpi.cancelled"), value: 3, change: "-5%", trend: "down" },
{ label: i18n.t("dashboard.kpi.completed"), value: 38, change: "+8%", trend: "up" },
{ label: i18n.t("dashboard.kpi.newClients"), value: 12, change: "+24%", trend: "up" },
],
staff: [{ id: "s1", name: cs ? "Petra M." : "Petra M.", role: cs ? "Specialista" : "Specialist", email: "petra@demo.cz" }],
upcomingBookings: allBookings.filter(b => b.status !== "cancelled" && b.status !== "completed"),
allBookings,
recentActivity: [
{ action: cs ? "Nová rezervace" : "New booking", detail: `${customers[0].name} - ${services[0]}`, time: cs ? "Dnes, 10:00" : "Today, 10:00", type: "booking" },
{ action: cs ? "Zrušení rezervace" : "Booking cancelled", detail: `${customers[3].name} - ${services[3]}`, time: cs ? "Včera" : "Yesterday", type: "cancel" },
{ action: cs ? "Dokončená rezervace" : "Booking completed", detail: `${customers[4].name} - ${services[1]}`, time: cs ? "Před 2 dny" : "2 days ago", type: "reminder" },
],
},
bootstrap: {
tenantName: cs ? "Demo Studio" : "Demo Studio",
slug: "demo-studio",
brand: { primaryColor: "#c25e3a" },
locale: cs ? "cs" : "en",
timezone: "Europe/Prague",
},
billing: {
planCode: "pro",
planName: "Pro",
subscriptionStatus: "trialing",
trialDaysRemaining: 12,
entitlements: { maxLocations: 3, maxBookings: -1, maxStaff: 10 },
billingProvider: "stripe",
},
};
}
export interface DashboardData {
i18n: ReturnType<typeof useI18n>;
auth: ReturnType<typeof useAuth>;
token: () => string | null | undefined;
isDemoMode: () => boolean;
resolvedSummary: () => any;
resolvedBootstrap: () => any;
resolvedBilling: () => any;
normalizedKpis: () => any[];
normalizedUpcomingBookings: () => any[];
normalizedRecentActivity: () => any[];
normalizedAllBookings: () => any[];
isDashboardReady: () => boolean;
locationData: () => number;
smsUsage: () => any;
demoNotice: () => string | null;
setDemoNotice: (v: string | null) => void;
billingNotice: () => string | null;
setBillingNotice: (v: string | null) => void;
billingError: () => string | null;
setBillingError: (v: string | null) => void;
billingAction: () => "checkout" | "refresh" | "portal" | null;
setBillingAction: (v: "checkout" | "refresh" | "portal" | null) => void;
billingInterval: () => "monthly" | "yearly";
setBillingInterval: (v: "monthly" | "yearly") => void;
openCheckout: () => Promise<void>;
openPortal: () => Promise<void>;
refreshBilling: () => Promise<void>;
showIntegrationModal: () => boolean;
setShowIntegrationModal: (v: boolean) => void;
showBookingDetail: () => boolean;
setShowBookingDetail: (v: boolean) => void;
selectedBooking: () => any;
setSelectedBooking: (v: any) => void;
openBookingDetail: (booking: any) => void;
}
const DashboardContext = createContext<DashboardData>();
export function useDashboardData() {
const ctx = useContext(DashboardContext);
if (!ctx) throw new Error("useDashboardData must be used within DashboardContext provider");
return ctx;
}
export function DashboardDataProvider(props: { children: any }) {
const i18n = useI18n();
const auth = useAuth();
const [billingInterval, setBillingInterval] = createSignal<"monthly" | "yearly">("monthly");
const [billingNotice, setBillingNotice] = createSignal<string | null>(null);
const [billingError, setBillingError] = createSignal<string | null>(null);
const [billingAction, setBillingAction] = createSignal<"checkout" | "refresh" | "portal" | null>(null);
const [showIntegrationModal, setShowIntegrationModal] = createSignal(false);
const [demoNotice, setDemoNotice] = createSignal<string | null>(null);
const [showBookingDetail, setShowBookingDetail] = createSignal(false);
const [selectedBooking, setSelectedBooking] = createSignal<any>(null);
const [token] = createResource(() => auth.session()?.session?.id, () => auth.getToken());
const isDemoMode = () => token()?.startsWith("demo.") ?? false;
const demoData = createMemo(() => createDemoData(i18n));
const [summary] = createResource(token, async (bearer) => {
if (!bearer) return null;
if (bearer.startsWith("demo.")) return demoData().summary;
const response = await apiClient.GET("/v1/dashboard/summary", { headers: { Authorization: `Bearer ${bearer}` } });
return response.data ?? null;
});
const [bootstrap] = createResource(token, async (bearer) => {
if (!bearer) return null;
if (bearer.startsWith("demo.")) return demoData().bootstrap;
const response = await apiClient.GET("/v1/tenants/bootstrap", { headers: { Authorization: `Bearer ${bearer}` } });
return response.data ?? null;
});
const [billing, { refetch: refetchBilling }] = createResource(token, async (bearer) => {
if (!bearer) return null;
if (bearer.startsWith("demo.")) return demoData().billing;
const response = await apiClient.GET("/v1/billing/subscription", { headers: { Authorization: `Bearer ${bearer}` } });
return response.data ?? null;
});
const [locationData] = createResource(token, async (bearer) => {
if (!bearer) return 0;
if (bearer.startsWith("demo.")) return 1;
try {
const response = await (apiClient as any).GET("/v1/catalog/locations", { headers: { Authorization: `Bearer ${bearer}` } });
return (response.data as any[])?.length ?? 0;
} catch { return 0; }
});
const [smsUsage] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return null;
try {
const response = await (apiClient as any).GET("/v1/sms/usage", { headers: { Authorization: `Bearer ${bearer}` } });
return response.data as any ?? null;
} catch { return null; }
});
const resolvedSummary = () => (summary.latest as any) ?? (isDemoMode() ? demoData().summary : undefined);
const resolvedBootstrap = () => (bootstrap.latest as any) ?? (isDemoMode() ? demoData().bootstrap : undefined);
const resolvedBilling = () => (billing.latest as any) ?? (isDemoMode() ? demoData().billing : undefined);
const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({
...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—",
})));
const normalizedUpcomingBookings = createMemo(() => (resolvedSummary()?.upcomingBookings ?? []).map((booking: any) => ({
...booking,
avatar: booking.avatar ?? getInitials(booking.customerName),
service: booking.service ?? booking.label ?? booking.reference ?? (i18n.locale() === "cs" ? "Rezervace" : "Booking"),
duration: booking.duration ?? getBookingDuration(booking.startsAt, booking.endsAt),
})));
const normalizedRecentActivity = createMemo(() => {
if (resolvedSummary()?.recentActivity?.length) return resolvedSummary().recentActivity;
return normalizedUpcomingBookings().slice(0, 5).map((booking: any) => ({
action: i18n.locale() === "cs" ? "Nadcházející rezervace" : "Upcoming booking",
detail: `${booking.customerName} \u2022 ${booking.service}`,
time: new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
type: "booking",
}));
});
const normalizedAllBookings = createMemo(() => resolvedSummary()?.allBookings ?? normalizedUpcomingBookings());
const isDashboardReady = () => !!token() && !bootstrap.loading && !summary.loading && !billing.loading;
const openCheckout = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze upravovat předplatné." : "Cannot modify billing in demo mode."); return; }
setBillingAction("checkout");
setBillingError(null);
try {
const response = await (apiClient as any).POST("/v1/billing/checkout", {
headers: { Authorization: `Bearer ${bearer}` },
body: { interval: billingInterval() },
});
if (response.data?.url) window.location.href = response.data.url;
else throw new Error("No checkout URL");
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
finally { setBillingAction(null); }
};
const openPortal = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze upravovat předplatné." : "Cannot modify billing in demo mode."); return; }
setBillingAction("portal");
setBillingError(null);
try {
const response = await (apiClient as any).POST("/v1/billing/portal", { headers: { Authorization: `Bearer ${bearer}` } });
if (response.data?.url) window.location.href = response.data.url;
else throw new Error("No portal URL");
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
finally { setBillingAction(null); }
};
const refreshBilling = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze obnovit předplatné." : "Cannot refresh billing in demo mode."); return; }
setBillingAction("refresh");
setBillingError(null);
try {
const response = await (apiClient as any).POST("/v1/billing/refresh", { headers: { Authorization: `Bearer ${bearer}` } });
if (response.data) { setBillingNotice(i18n.locale() === "cs" ? "Předplatné obnoveno." : "Billing refreshed."); void refetchBilling(); }
} catch (err) { setBillingError(err instanceof Error ? err.message : String(err)); }
finally { setBillingAction(null); }
};
const openBookingDetail = (booking: any) => {
setSelectedBooking(booking);
setShowBookingDetail(true);
};
const value: DashboardData = {
i18n,
auth,
token: () => token(),
isDemoMode,
resolvedSummary,
resolvedBootstrap,
resolvedBilling,
normalizedKpis,
normalizedUpcomingBookings,
normalizedRecentActivity,
normalizedAllBookings,
isDashboardReady,
locationData: () => locationData() ?? 0,
smsUsage: () => smsUsage.latest,
demoNotice,
setDemoNotice,
billingNotice,
setBillingNotice,
billingError,
setBillingError,
billingAction,
setBillingAction,
billingInterval,
setBillingInterval,
openCheckout,
openPortal,
refreshBilling,
showIntegrationModal,
setShowIntegrationModal,
showBookingDetail,
setShowBookingDetail,
selectedBooking,
setSelectedBooking,
openBookingDetail,
};
return value;
}
export { DashboardContext };
@@ -0,0 +1,344 @@
import { Show, createSignal, type JSX, type ParentComponent } from "solid-js";
import { A, useLocation } from "@solidjs/router";
import { useTheme } from "../../providers/theme-provider";
import { BookraCharacter } from "../../components/bookra-character";
import { IntegrationModal } from "../../components/integration-modal";
import { NotificationDropdown } from "../../components/dashboard/notification-dropdown";
import { getInitials, getBookingDuration } from "../../components/dashboard/types";
import {
LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon,
LogOutIcon, MenuIcon, XIcon, SparklesIcon, GlobeIcon, SunIcon, MoonIcon,
UserCircleIcon, MapPinIcon
} from "../../components/dashboard/icons";
import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data";
export { useDashboardData };
const navItems = [
{ id: "overview", label: "dashboard.overview", icon: LayoutDashboardIcon, path: "/dashboard" },
{ id: "bookings", label: "dashboard.bookings", icon: CalendarDaysIcon, path: "/dashboard/bookings" },
{ id: "customers", label: "dashboard.customers", icon: UserCircleIcon, path: "/dashboard/customers" },
{ id: "zones", label: "dashboard.zones", icon: MapPinIcon, path: "/dashboard/zones" },
{ id: "billing", label: "dashboard.billing", icon: CreditCardIcon, path: "/dashboard/billing" },
{ id: "settings", label: "dashboard.settings", icon: Settings2Icon, path: "/dashboard/settings" },
];
function DashboardShell(props: { children: JSX.Element }) {
const data = useDashboardData();
const theme = useTheme();
const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
const activeSection = () => {
const p = location.pathname;
if (p === "/dashboard" || p === "/dashboard/") return "overview";
const match = navItems.find(item => p === item.path || p.startsWith(item.path + "/"));
return match?.id ?? "overview";
};
const isDark = () => theme.resolvedTheme() === "dark";
const DashboardLogo = (props: { class?: string }) => (
<div class={`relative h-8 ${props.class ?? ""}`}>
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
</div>
);
const LanguageToggle = () => (
<button
onClick={() => data.i18n.toggleLocale()}
class="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle rounded-lg transition-all"
title={data.i18n.t("dashboard.language")}
>
<GlobeIcon />
{data.i18n.locale() === "cs" ? "CS" : "EN"}
</button>
);
const ThemeToggle = () => (
<button
onClick={() => theme.toggle()}
class="p-2 text-ink-subtle hover:text-ink hover:bg-canvas-subtle rounded-xl transition-all"
aria-label="Toggle theme"
>
{theme.resolvedTheme() === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
);
const DemoBanner = () => (
<Show when={data.isDemoMode()}>
<div class="mb-6 p-4 rounded-xl bg-accent-subtle border border-accent/20 flex items-center justify-between animate-fade-in">
<div class="flex items-center gap-3 min-w-0">
<SparklesIcon />
<div class="min-w-0">
<p class="text-sm font-semibold text-accent">{data.i18n.t("dashboard.demoBanner")}</p>
<p class="text-xs text-accent/70">{data.i18n.t("dashboard.demoBannerDesc")}</p>
</div>
</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))}
class="shrink-0 ml-4 px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors"
>
{data.i18n.t("dashboard.demoBannerCTA")}
</button>
</div>
</Show>
);
const Sidebar = () => (
<aside class="hidden lg:flex flex-col w-64 h-screen bg-canvas border-r border-border sticky top-0 shrink-0">
<div class="p-6">
<A href="/" class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-colors">
<DashboardLogo />
</A>
</div>
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<A
href={item.path}
class={`
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
${activeSection() === item.id
? "bg-accent text-canvas shadow-sm"
: "text-ink-muted hover:bg-canvas-subtle hover:text-ink"
}
`}
>
<item.icon /> {data.i18n.t(item.label)}
</A>
))}
</nav>
<div class="p-4 border-t border-border space-y-1">
<div class="flex items-center gap-2 px-4 py-2">
<LanguageToggle />
<ThemeToggle />
</div>
<Show when={data.auth.session()?.user}>
<div class="flex items-center gap-3 px-4 py-3">
<div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">
{getInitials(data.auth.session()?.user?.name)}
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-ink truncate">{data.auth.session()?.user?.name}</p>
<p class="text-xs text-ink-muted truncate">{data.auth.session()?.user?.email}</p>
</div>
</div>
</Show>
<button
onClick={() => void data.auth.signOut()}
class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all group"
>
<span class="group-hover:translate-x-0.5 transition-transform"><LogOutIcon /></span>
{data.i18n.t("auth.signOut")}
</button>
</div>
</aside>
);
const MobileMenu = () => (
<Show when={isMobileMenuOpen()}>
<div class="lg:hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm transition-opacity" onClick={() => setIsMobileMenuOpen(false)} />
<div class="absolute left-0 top-0 h-full w-72 bg-canvas shadow-2xl">
<div class="p-4 border-b border-border flex items-center justify-between">
<DashboardLogo />
<button onClick={() => setIsMobileMenuOpen(false)} class="p-2 hover:bg-canvas-subtle rounded-xl transition-colors" aria-label={data.i18n.t("dashboard.close")}>
<XIcon />
</button>
</div>
<nav class="p-4 space-y-1">
{navItems.map((item) => (
<A
href={item.path}
onClick={() => setIsMobileMenuOpen(false)}
class={`
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all
${activeSection() === item.id ? "bg-accent text-canvas" : "text-ink-muted hover:bg-canvas-subtle"}
`}
>
<item.icon /> {data.i18n.t(item.label)}
</A>
))}
</nav>
<div class="p-4 border-t border-border">
<div class="flex items-center gap-2 mb-2">
<LanguageToggle />
<ThemeToggle />
</div>
<button onClick={() => void data.auth.signOut()} class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all">
<LogOutIcon /> {data.i18n.t("auth.signOut")}
</button>
</div>
</div>
</div>
</Show>
);
const BookingDetailModal = () => (
<Show when={data.showBookingDetail() && data.selectedBooking()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => data.setShowBookingDetail(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto animate-scale-in">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{data.i18n.t("dashboard.bookingDetails")}</h3>
<button onClick={() => data.setShowBookingDetail(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.close")}>
<XIcon />
</button>
</div>
<div class="p-6 space-y-5">
<div class="flex items-center gap-4 p-4 bg-canvas-subtle rounded-xl">
<div class="w-14 h-14 rounded-full bg-accent-subtle flex items-center justify-center text-lg font-bold text-accent">
{getInitials(data.selectedBooking()?.customerName)}
</div>
<div class="min-w-0">
<p class="font-semibold text-ink text-lg">{data.selectedBooking()?.customerName}</p>
<p class="text-ink-muted">{data.selectedBooking()?.customerEmail}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.service")}</p>
<p class="font-medium text-ink">{data.selectedBooking()?.service}</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.location")}</p>
<p class="font-medium text-ink">{data.selectedBooking()?.location || "\u2014"}</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl col-span-2">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.assignedTo")}</p>
<Show when={data.selectedBooking()?.staffName} fallback={<p class="text-ink-subtle">{data.i18n.t("dashboard.bookingModal.noAssign")}</p>}>
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-accent-subtle flex items-center justify-center text-[10px] font-bold text-accent">
{data.selectedBooking()?.staffName?.split(" ").map((n: string) => n[0]).join("")}
</div>
<p class="font-medium text-ink">{data.selectedBooking()?.staffName}</p>
</div>
</Show>
</div>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.dateTime")}</p>
<p class="font-medium text-ink">
{new Date(data.selectedBooking()?.startsAt).toLocaleString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</p>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.bookingModal.duration")}: {getBookingDuration(data.selectedBooking()?.startsAt, data.selectedBooking()?.endsAt)}</p>
</div>
<div class="flex items-center justify-between p-4 bg-canvas-subtle rounded-xl">
<div>
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.reference")}</p>
<p class="font-medium text-ink font-mono text-sm">{data.selectedBooking()?.reference}</p>
</div>
<span class={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
data.selectedBooking()?.status === "confirmed" ? "bg-accent/10 text-accent"
: data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
{data.i18n.t(`dashboard.${data.selectedBooking()?.status}`)}
</span>
</div>
<Show when={data.selectedBooking()?.notes}>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.notes")}</p>
<p class="text-ink text-sm">{data.selectedBooking()?.notes}</p>
</div>
</Show>
</div>
</div>
</div>
</Show>
);
return (
<div class="min-h-screen flex bg-canvas font-dashboard">
<Sidebar />
<div class="flex-1 flex flex-col min-h-screen">
{/* Mobile Header */}
<div class="lg:hidden bg-canvas/80 backdrop-blur-xl border-b border-border p-4 flex items-center justify-between sticky top-0 z-40">
<DashboardLogo />
<div class="flex items-center gap-2">
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevrit menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
<MenuIcon />
</button>
</div>
</div>
<MobileMenu />
<main class="flex-1 p-4 lg:p-8 overflow-y-auto">
<DemoBanner />
{/* Demo Notice Toast */}
<Show when={data.demoNotice()}>
<div class="mb-6 p-4 rounded-xl bg-accent-subtle border border-accent/20 flex items-center justify-between animate-fade-in">
<div class="flex items-center gap-3">
<SparklesIcon />
<p class="text-sm font-medium text-accent">{data.demoNotice()}</p>
</div>
<button onClick={() => data.setDemoNotice(null)} class="p-1.5 hover:bg-accent/10 rounded-lg text-accent"><XIcon /></button>
</div>
</Show>
{/* Not logged in */}
<Show when={!data.token() && data.auth.session() !== undefined}>
<div class="max-w-lg mx-auto py-12 lg:py-20 px-4 text-center">
<div class="relative inline-block mb-6">
<div class="absolute inset-0 bg-accent/20 rounded-full blur-3xl opacity-60" />
<BookraCharacter pose="hello" size="xl" animate={true} />
</div>
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{data.i18n.locale() === "cs" ? "Vas rezervacni system ceka" : "Your booking system awaits"}</h2>
<p class="text-ink-muted mt-3 text-lg max-w-md mx-auto">{data.i18n.t("dashboard.welcome.body")}</p>
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "sign-in" } }))} class="btn-primary w-full sm:w-auto">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
{data.i18n.t("auth.signIn")}
</button>
<button onClick={async () => { await data.auth.signInAsDemo(); window.location.reload(); }} class="btn-secondary w-full sm:w-auto">
<SparklesIcon /> {data.i18n.t("dashboard.tryDemo")}
</button>
</div>
<p class="mt-6 text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Jeste nemate ucet? " : "No account yet? "}
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))} class="text-accent hover:text-accent-hover font-medium underline underline-offset-2">{data.i18n.t("auth.createAccount")}</button>
</p>
</div>
</Show>
{/* Loading */}
<Show when={data.token() && !data.isDashboardReady()}>
<div class="flex flex-col items-center justify-center py-20">
<BookraCharacter pose="walk" size="lg" animate={true} />
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{data.i18n.locale() === "cs" ? "Nacitani dashboardu..." : "Loading your dashboard..."}</p>
</div>
</Show>
{/* Dashboard Content */}
<Show when={data.isDashboardReady()}>
<div class="max-w-7xl mx-auto">
{props.children}
</div>
</Show>
</main>
</div>
<IntegrationModal
isOpen={data.showIntegrationModal()}
onClose={() => data.setShowIntegrationModal(false)}
tenantSlug={data.resolvedSummary()?.tenantSlug || "demo-studio"}
publicBookingUrl={data.resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio"}
tenantName={data.resolvedSummary()?.tenantName || "Demo Studio"}
primaryColor={data.resolvedBootstrap()?.brand?.primaryColor}
/>
<BookingDetailModal />
</div>
);
}
export const DashboardLayout: ParentComponent = (props) => {
const value = DashboardDataProvider({ children: undefined } as any);
return (
<DashboardContext.Provider value={value}>
<DashboardShell>{props.children}</DashboardShell>
</DashboardContext.Provider>
);
};
@@ -0,0 +1,223 @@
import { A, useNavigate } from "@solidjs/router";
import { Show } from "solid-js";
import { CalendarView } from "../../components/dashboard/calendar-view";
import { RevenueChart } from "../../components/dashboard/revenue-chart";
import { KpiCard } from "../../components/dashboard/kpi-card";
import { ActivityTimeline } from "../../components/dashboard/activity-timeline";
import { NotificationDropdown } from "../../components/dashboard/notification-dropdown";
import { getInitials } from "../../components/dashboard/types";
import { ChevronRightIcon, MailIcon, SparklesIcon } from "../../components/dashboard/icons";
import { PinnedList } from "../../components/pinned-list";
import { DashboardLayout, useDashboardData } from "./layout";
function OverviewPage() {
const data = useDashboardData();
const navigate = useNavigate();
return (
<div class="space-y-6">
{/* Header */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink tracking-tight font-display">
{data.i18n.t("dashboard.welcome")} <span class="text-accent">{data.auth.session()?.user?.name}</span>
</h1>
<p class="text-ink-muted mt-1">{data.i18n.t("dashboard.overviewFor")} {data.resolvedBootstrap()?.tenantName}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<button onClick={() => data.setShowIntegrationModal(true)} class="btn-secondary text-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
{data.i18n.t("dashboard.shareManage")}
</button>
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
</div>
</div>
{/* KPI Cards */}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{data.normalizedKpis().map((kpi: any, index: number) => (
<KpiCard kpi={kpi} index={index} />
))}
</div>
{/* Setup Guide */}
<div class="grid lg:grid-cols-3 gap-6">
<div class="lg:col-span-1 surface-card p-5">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-accent-subtle flex items-center justify-center text-accent">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/></svg>
</div>
<div>
<h3 class="font-semibold text-ink">{data.i18n.locale() === "cs" ? "Začínáme" : "Getting Started"}</h3>
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Dokončete nastavení" : "Complete your setup"}</p>
</div>
</div>
<PinnedList
items={[
{
id: "setup-locations",
name: data.i18n.locale() === "cs" ? "Přidat lokace" : "Add locations",
subtitle: data.i18n.locale() === "cs" ? "Salon, klinika, studio" : "Salon, clinic, studio",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>,
href: "/dashboard/zones",
},
{
id: "setup-hours",
name: data.i18n.locale() === "cs" ? "Nastavit provozní dobu" : "Set business hours",
subtitle: data.i18n.locale() === "cs" ? "PoNe, svátky" : "MonSun, holidays",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
href: "/dashboard/zones",
},
{
id: "setup-services",
name: data.i18n.locale() === "cs" ? "Definovat služby" : "Define services",
subtitle: data.i18n.locale() === "cs" ? "Ceník, délka, kapacita" : "Pricing, duration, capacity",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
href: "/dashboard/settings",
},
{
id: "setup-widget",
name: data.i18n.locale() === "cs" ? "Vložit rezervační widget" : "Embed booking widget",
subtitle: data.i18n.locale() === "cs" ? "Web, Instagram, email" : "Website, Instagram, email",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>,
href: "/dashboard/settings",
},
{
id: "setup-team",
name: data.i18n.locale() === "cs" ? "Přidat členy týmu" : "Add team members",
subtitle: data.i18n.locale() === "cs" ? "Role, oprávnění" : "Roles, permissions",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
href: "/dashboard/settings",
},
{
id: "setup-branding",
name: data.i18n.locale() === "cs" ? "Upravit branding" : "Customize branding",
subtitle: data.i18n.locale() === "cs" ? "Barva, logo, jazyk" : "Color, logo, language",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.01 17.461 2 12 2z"/></svg>,
href: "/dashboard/settings",
},
]}
pinnedLabel={data.i18n.locale() === "cs" ? "Připnuto" : "Pinned"}
allLabel={data.i18n.locale() === "cs" ? "Zbývá" : "Remaining"}
initialPinned={["setup-locations"]}
/>
</div>
<div class="lg:col-span-2">
<RevenueChart />
</div>
</div>
{/* SMS Usage */}
{(() => {
const usage = data.smsUsage();
if (!usage || usage.messageCount === 0) return null;
return (
<div class="surface-card p-5 border-l-4 border-accent">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent">
<MailIcon />
</div>
<div>
<p class="font-semibold text-ink">SMS {data.i18n.t("dashboard.billing.planUsage")}</p>
<p class="text-sm text-ink-muted">{usage.messageCount} messages &mdash; {(usage.totalCostCents / 100).toFixed(2)} Kc</p>
</div>
</div>
<A href="/dashboard/settings" class="text-sm font-medium text-accent hover:text-accent-hover transition-colors">
{data.i18n.t("dashboard.settings")}
</A>
</div>
</div>
);
})()}
{/* Plan Limit Warning */}
{(() => {
const entitlements = data.resolvedBilling()?.entitlements;
const limit = entitlements?.maxLocations ?? -1;
const count = data.locationData() ?? 0;
const isNear = limit > 0 && count >= limit * 0.8;
const isAt = limit > 0 && count >= limit;
if (!isNear && !isAt) return null;
return (
<div class={`p-4 rounded-xl border ${isAt ? "bg-canvas-subtle border-ink/10" : "bg-accent/5 border-accent/20"}`}>
<div class="flex items-center gap-3">
<div class={`w-10 h-10 rounded-full flex items-center justify-center ${isAt ? "bg-canvas-muted" : "bg-accent/10"}`}>
<SparklesIcon />
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-ink">{isAt ? data.i18n.t("dashboard.locationLimitReached") : data.i18n.t("dashboard.nearingLocationLimit")}</p>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.locationsUsed")} {count}/{limit === -1 ? "\u221e" : limit}</p>
</div>
<button onClick={() => void data.openCheckout()} class="px-4 py-2 text-sm font-medium rounded-lg bg-accent text-canvas hover:bg-accent-hover transition-colors shrink-0">
{data.i18n.t("dashboard.upgrade")}
</button>
</div>
</div>
);
})()}
{/* Revenue Chart + Calendar + Activity */}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<RevenueChart bookings={data.normalizedAllBookings()} />
<CalendarView bookings={data.normalizedUpcomingBookings()} locale={data.i18n.locale()} onBookingClick={data.openBookingDetail} />
</div>
<div class="space-y-6">
<ActivityTimeline activities={data.normalizedRecentActivity()} />
</div>
</div>
{/* Upcoming Bookings */}
<div class="surface-card p-6 shadow-sm">
<div class="flex items-center justify-between mb-5">
<div>
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.upcomingBookings")}</h3>
<p class="text-sm text-ink-muted mt-0.5">{data.normalizedUpcomingBookings().length} total</p>
</div>
<A href="/dashboard/bookings" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-accent hover:text-accent-hover hover:bg-accent-subtle rounded-xl transition-all">
{data.i18n.t("dashboard.viewAll")} <ChevronRightIcon />
</A>
</div>
<div class="space-y-3">
{data.normalizedUpcomingBookings().slice(0, 5).map((booking: any) => (
<button
onClick={() => data.openBookingDetail(booking)}
class="w-full text-left flex items-center gap-4 p-4 rounded-xl border border-border/60 hover:border-accent/30 hover:bg-accent-subtle/20 transition-all group"
>
<div class="w-11 h-11 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">
{getInitials(booking.customerName)}
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-ink group-hover:text-accent transition-colors">{booking.customerName}</p>
<p class="text-sm text-ink-muted">{booking.service} &bull; {booking.duration}</p>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-medium text-ink">{new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}</p>
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
{data.i18n.t(`dashboard.${booking.status}`)}
</span>
</div>
</button>
))}
</div>
</div>
</div>
);
}
export default function OverviewRoute() {
return (
<DashboardLayout>
<OverviewPage />
</DashboardLayout>
);
}
@@ -0,0 +1,140 @@
import { Show, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { PaletteIcon, MailIcon, CheckCircleIcon, BellIcon, XCircleIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
import { SMSSettings } from "../../components/sms-settings";
import { WidgetBuilder } from "../../components/widget-builder";
import { DashboardLayout, useDashboardData } from "./layout";
function SettingsPage() {
const data = useDashboardData();
const [brandColor, setBrandColor] = createSignal(data.resolvedBootstrap()?.brand?.primaryColor ?? "#c25e3a");
const [brandSaving, setBrandSaving] = createSignal(false);
const [activeEmailType, setActiveEmailType] = createSignal("confirmation");
const [emailSubject, setEmailSubject] = createSignal("");
const [emailBody, setEmailBody] = createSignal("");
const [emailSaving, setEmailSaving] = createSignal(false);
const presetColors = [
{ name: data.i18n.locale() === "cs" ? "Hnědá" : "Terracotta", color: "#c25e3a" },
{ name: data.i18n.locale() === "cs" ? "Zelená" : "Sage", color: "#5a7c5a" },
{ name: data.i18n.locale() === "cs" ? "Modrá" : "Ocean", color: "#3a6a8a" },
{ name: data.i18n.locale() === "cs" ? "Fialová" : "Plum", color: "#6a4a7a" },
];
const handleSaveBrand = async () => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setBrandSaving(true);
try {
await (apiClient as any).PUT("/v1/tenants/brand", { headers: { Authorization: `Bearer ${bearer}` }, body: { primaryColor: brandColor() } });
} catch { /* ignore */ }
finally { setBrandSaving(false); }
};
return (
<div class="space-y-6">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.settings")}</h1>
<p class="text-ink-muted mt-1">{data.i18n.locale() === "cs" ? "Spravujte svůj podnik, branding a předvolby." : "Manage your business, branding and preferences."}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><PaletteIcon /></div>
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.settings.branding")}</h3>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-ink-muted mb-3">{data.i18n.t("dashboard.settings.branding")}</label>
<div class="grid grid-cols-4 gap-2 mb-3">
{presetColors.map((preset) => (
<button onClick={() => setBrandColor(preset.color)} class={`p-2 rounded-lg border-2 transition-all ${brandColor() === preset.color ? "border-ink" : "border-transparent hover:border-ink/20"}`}>
<div class="w-full h-8 rounded-md shadow-sm" style={{ background: preset.color }} />
<p class="text-xs text-ink-muted mt-1">{preset.name}</p>
</button>
))}
</div>
<div class="flex items-center gap-3">
<input type="color" value={brandColor()} onInput={(e) => setBrandColor(e.currentTarget.value)} class="w-12 h-10 rounded-lg border border-border cursor-pointer" />
<input type="text" value={brandColor()} onInput={(e) => setBrandColor(e.currentTarget.value)} class="flex-1 px-4 py-2.5 bg-canvas-subtle border border-border rounded-xl text-ink text-sm font-mono uppercase" />
</div>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-3">{data.i18n.t("dashboard.preview")}</p>
<div class="p-4 rounded-xl border border-border" style={{ "border-left": `4px solid ${brandColor()}` }}>
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-canvas font-bold" style={{ background: brandColor() }}>B</div>
<div>
<p class="font-semibold text-ink">Bookra</p>
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Rezervujte si termin" : "Book an appointment"}</p>
</div>
</div>
<button class="w-full py-2.5 rounded-lg text-canvas font-medium text-sm" style={{ background: brandColor() }}>{data.i18n.locale() === "cs" ? "Rezervovat" : "Book Now"}</button>
</div>
</div>
<button onClick={handleSaveBrand} disabled={brandSaving()} class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium disabled:opacity-60 flex items-center justify-center gap-2">
<Show when={brandSaving()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
{brandSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
</div>
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><MailIcon /></div>
<div>
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.settings.emailNotifications")}</h3>
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Upravte e-maily posílané zákazníkům." : "Customize emails sent to customers."}</p>
</div>
</div>
<div class="flex gap-2 mb-4 overflow-x-auto pb-2">
{[
{ key: "confirmation", label: data.i18n.t("dashboard.confirmed"), icon: CheckCircleIcon },
{ key: "reminder", label: data.i18n.t("dashboard.notifications"), icon: BellIcon },
{ key: "cancellation", label: data.i18n.t("dashboard.cancelled"), icon: XCircleIcon },
{ key: "reschedule", label: data.i18n.t("dashboard.edit"), icon: CalendarDaysIcon },
].map((type) => (
<button onClick={() => setActiveEmailType(type.key)} class={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${activeEmailType() === type.key ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>
<type.icon /> {type.label}
</button>
))}
</div>
<div class="space-y-4">
<Input type="text" label={data.i18n.t("dashboard.settings.emailSubject")} value={emailSubject()} onInput={(e) => setEmailSubject(e.currentTarget.value)} placeholder={activeEmailType() === "confirmation" ? (data.i18n.locale() === "cs" ? "Potvrzení rezervace" : "Booking Confirmation") : ""} />
<Textarea label={data.i18n.t("dashboard.settings.emailBody")} value={emailBody()} onInput={(e) => setEmailBody(e.currentTarget.value)} rows={6} resize="none" placeholder={data.i18n.locale() === "cs" ? "Obsah e-mailu..." : "Email body..."} />
</div>
<button onClick={async () => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setEmailSaving(true);
try {
await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody() } });
data.setDemoNotice(data.i18n.locale() === "cs" ? "Šablona uložena." : "Template saved.");
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Chyba při ukládání." : "Save failed."); }
finally { setEmailSaving(false); }
}} disabled={emailSaving()} class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium disabled:opacity-60 flex items-center justify-center gap-2">
<Show when={emailSaving()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
{emailSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
</div>
</div>
<SMSSettings token={data.token} />
<WidgetBuilder config={{
tenantSlug: data.resolvedSummary()?.tenantSlug || "demo-studio",
publicBookingUrl: data.resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio",
tenantName: data.resolvedSummary()?.tenantName || "Demo Studio",
primaryColor: data.resolvedBootstrap()?.brand?.primaryColor,
}} />
</div>
);
}
export default function SettingsRoute() {
return (
<DashboardLayout>
<SettingsPage />
</DashboardLayout>
);
}
@@ -0,0 +1,156 @@
import { Show, createSignal } from "solid-js";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { LocationMap } from "../../components/location-map";
import { MapPinIcon, PlusIcon, XIcon, BanIcon, ClockIcon, SparklesIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() {
const data = useDashboardData();
const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours">("zones");
const [showAddZone, setShowAddZone] = createSignal(false);
const [newZoneName, setNewZoneName] = createSignal("");
const [newZoneType, setNewZoneType] = createSignal("room");
const [newZoneCapacity, setNewZoneCapacity] = createSignal(10);
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
const resolvedLocations = () => [
{ id: "z1", name: "Main Hall", type: "hall", capacity: 20, bookingsToday: 5 },
{ id: "z2", name: "Private Room A", type: "private", capacity: 4, bookingsToday: 2 },
{ id: "z3", name: "Treatment Room", type: "room", capacity: 2, bookingsToday: 8 },
];
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
const canAddLocation = () => resolvedLocations().length < locationLimit();
const handleAddZone = () => {
if (!canAddLocation()) { setShowUpgradePrompt(true); setShowAddZone(false); return; }
setNewZoneName(""); setNewZoneType("room"); setNewZoneCapacity(10); setShowAddZone(false);
};
const typeLabel = (type: string) => {
const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") };
return map[type] || type;
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.zones")}</h1>
<p class="text-ink-muted mt-1">{resolvedLocations().length} {data.i18n.locale() === "cs" ? "zon" : "zones"}</p>
</div>
</div>
<div class="flex gap-2 p-1 bg-canvas-subtle rounded-xl w-fit">
{[
{ id: "zones", label: data.i18n.t("dashboard.zones"), icon: MapPinIcon },
{ id: "blocked", label: data.i18n.t("dashboard.zone.blockedDays"), icon: BanIcon },
{ id: "hours", label: data.i18n.t("dashboard.zone.workingHours"), icon: ClockIcon },
].map((tab) => (
<button onClick={() => setActiveTab(tab.id as any)} class={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${activeTab() === tab.id ? "bg-canvas text-accent shadow-sm" : "text-ink-muted hover:text-ink"}`}>
<tab.icon /> {tab.label}
</button>
))}
</div>
{activeTab() === "zones" && (
<div class="space-y-6">
<LocationMap
coordinates={{ latitude: 50.0755, longitude: 14.4378, zoom: 14, address: data.i18n.locale() === "cs" ? "Praha, Česko" : "Prague, Czechia", source: "coordinates" }}
markerColor={data.resolvedBootstrap()?.brand?.primaryColor}
height={320}
class="shadow-sm"
/>
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zones")}</h3>
<Show when={canAddLocation()} fallback={
<button onClick={() => setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-60 cursor-not-allowed">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
{data.i18n.t("dashboard.zone.limitReached")}
</button>
}>
<button onClick={() => setShowAddZone(true)} class="btn-primary text-sm"><PlusIcon /> {data.i18n.t("dashboard.zone.add")}</button>
</Show>
</div>
<Show when={showAddZone()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{data.i18n.t("dashboard.zone.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input type="text" label={data.i18n.t("dashboard.zone.name")} value={newZoneName()} onInput={(e) => setNewZoneName(e.currentTarget.value)} placeholder={data.i18n.locale() === "cs" ? "např. Hlavní sál" : "e.g. Main Hall"} />
<Select label={data.i18n.t("dashboard.zone.type")} value={newZoneType()} onChange={(v) => setNewZoneType(v)} options={[{ value: "room", label: data.i18n.t("dashboard.zone.rooms") }, { value: "private", label: data.i18n.t("dashboard.zone.private") }, { value: "hall", label: data.i18n.t("dashboard.zone.hall") }]} />
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newZoneCapacity()} onInput={(e) => setNewZoneCapacity(parseInt(e.currentTarget.value) || 10)} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={handleAddZone} class="btn-primary text-sm">{data.i18n.t("dashboard.settings.save")}</button>
<button onClick={() => setShowAddZone(false)} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show>
<div class="divide-y divide-border/60">
{resolvedLocations().map((zone: any) => (
<div class="flex items-center justify-between p-6 hover:bg-canvas-subtle/30 transition-colors">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><MapPinIcon /></div>
<div>
<h4 class="font-medium text-ink">{zone.name}</h4>
<p class="text-sm text-ink-muted">{typeLabel(zone.type)} &bull; {data.i18n.t("dashboard.zone.capacity")}: {zone.capacity}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium text-ink">{zone.bookingsToday}</p>
<p class="text-sm text-ink-muted">{zone.bookingsToday} {data.i18n.locale() === "cs" ? "rezervací dnes" : "bookings today"}</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
{activeTab() === "blocked" && (
<div class="surface-card p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><BanIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Spravujte zóny, blokované dny a pracovní dobu." : "Manage locations, blocked days and working hours."}</p>
</div>
)}
{activeTab() === "hours" && (
<div class="surface-card p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4"><ClockIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-ink-muted text-sm">{data.i18n.locale() === "cs" ? "Nastavení pracovní doby bude dostupné brzy." : "Working hours settings coming soon."}</p>
</div>
)}
<Show when={showUpgradePrompt()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowUpgradePrompt(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md p-6 animate-scale-in">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center shrink-0"><SparklesIcon /></div>
<div class="flex-1">
<h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.locationLimitReached")}</h4>
<p class="text-sm text-ink-muted">{data.i18n.locale() === "cs" ? "Váš plán umožňuje pouze omezený počet lokací. Pro více lokací upgradujte." : "Your plan only allows a limited number of locations. Upgrade for more."}</p>
<div class="flex gap-3">
<button onClick={() => { setShowUpgradePrompt(false); void data.openCheckout(); }} class="px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">{data.i18n.t("dashboard.upgrade")}</button>
<button onClick={() => setShowUpgradePrompt(false)} class="px-4 py-2 bg-canvas-subtle text-ink-muted text-sm font-medium rounded-lg hover:bg-canvas transition-colors">{data.i18n.t("dashboard.close")}</button>
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function ZonesRoute() {
return (
<DashboardLayout>
<ZonesPage />
</DashboardLayout>
);
}
+416 -11
View File
@@ -1,7 +1,24 @@
import { A } from "@solidjs/router";
import { A, useNavigate } from "@solidjs/router";
import { createSignal, onMount, createMemo, Show, For } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { useTheme } from "../providers/theme-provider";
import { BookraCharacter } from "../components/bookra-character";
import { HoverFeatureCards } from "../components/hover-feature-cards";
import { DashboardMockup } from "../components/dashboard-mockup";
import { VideoPlayer } from "../components/video-player";
import { AnimatedList } from "../components/animated-list";
import { FloatingDock } from "../components/floating-dock";
const HomeLogo = () => {
const theme = useTheme();
const isDark = () => theme.resolvedTheme() === "dark";
return (
<div class="relative h-9">
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-9 w-auto opacity-75 absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-9 w-auto opacity-75 absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
</div>
);
};
// Lucide-like icon components (lightweight SVG)
const CalendarIcon = () => (
@@ -209,8 +226,17 @@ export function HomeRoute() {
const [isVisible, setIsVisible] = createSignal(false);
const [currentDate, setCurrentDate] = createSignal(new Date());
const navigate = useNavigate();
onMount(() => {
setIsVisible(true);
// Demo mode: redirect landing page to dashboard immediately
const hostname = window.location.hostname;
const isDemo = hostname.includes("demo") || hostname === "localhost" || hostname === "127.0.0.1";
if (isDemo) {
navigate("/dashboard");
}
});
// Calendar helpers
@@ -453,20 +479,27 @@ export function HomeRoute() {
<section class="py-12 border-y border-border/50 bg-canvas-subtle/50">
<div class="section-container">
<div class="mb-6 flex justify-center">
<img
src="/bookra-illustrations/logo_text_horizontal.svg"
alt="Bookra"
class="h-9 w-auto opacity-75"
/>
<HomeLogo />
</div>
<p class="text-center text-sm text-ink-subtle mb-8 tracking-wide uppercase">
{i18n.t("home.trust")}
</p>
<div class="flex flex-wrap items-center justify-center gap-8 lg:gap-16 opacity-60">
{["Salon Ella", "Physio Care", "Massage Studio", "Yoga Flow", "Repair Pro"].map((name) => (
<span class="text-lg lg:text-xl font-medium text-ink-muted tracking-tight">
{name}
</span>
<div class="flex flex-wrap items-center justify-center gap-4 lg:gap-6">
{[
{ name: "Salon Ella", icon: "SE" },
{ name: "Physio Care", icon: "PC" },
{ name: "Massage Studio", icon: "MS" },
{ name: "Yoga Flow", icon: "YF" },
{ name: "Repair Pro", icon: "RP" },
].map((biz) => (
<div class="flex items-center gap-2.5 px-4 py-2.5 rounded-xl bg-canvas border border-border/60 shadow-sm hover:shadow-md hover:border-border transition-all duration-300 group">
<div class="w-8 h-8 rounded-lg bg-accent-subtle/60 flex items-center justify-center text-[10px] font-bold text-accent tracking-wide">
{biz.icon}
</div>
<span class="text-sm font-medium text-ink-muted group-hover:text-ink transition-colors tracking-tight">
{biz.name}
</span>
</div>
))}
</div>
</div>
@@ -503,6 +536,164 @@ export function HomeRoute() {
</div>
</section>
{/* Product Showcase — Hover Feature Cards */}
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-2xl mx-auto text-center mb-16 lg:mb-20">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{isCs() ? "Prozkoumejte" : "Explore"}
</span>
<h2 class="text-display-md font-semibold text-ink mb-4 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs() ? "Vše, co potřebujete, na jednom místě" : "Everything you need in one place"}
</h2>
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{isCs()
? "Náhled do aplikace — rezervace, zákazníci, analytika i nastavení."
: "A peek inside the app — bookings, customers, analytics, and settings."}
</p>
</div>
<HoverFeatureCards
items={[
{
name: isCs() ? "Přehledný dashboard" : "Clean Dashboard",
description: isCs()
? "Sledujte KPI, trendy rezervací a dnešní agenda v reálném čase."
: "Track KPIs, booking trends, and today's agenda in real time.",
href: "/dashboard",
children: <DashboardMockup />,
fadeBottom: true,
},
{
name: isCs() ? "Kalendář rezervací" : "Booking Calendar",
description: isCs()
? "Intuitivní týdenní a měsíční pohled s drag-and-drop plánováním."
: "Intuitive weekly and monthly views with drag-and-drop scheduling.",
href: "/dashboard/bookings",
children: (
<div class="w-full h-full flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[10px] font-medium text-ink-muted">Květen 2026</span>
<div class="flex gap-1">
<div class="w-5 h-5 rounded border border-border flex items-center justify-center text-ink-subtle">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
</div>
<div class="w-5 h-5 rounded border border-border flex items-center justify-center text-ink-subtle">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
</div>
</div>
</div>
<div class="grid grid-cols-7 gap-px bg-border/40 rounded-lg overflow-hidden">
{["Po","Út","St","Čt","Pá","So","Ne"].map((d) => (
<div class="bg-canvas-subtle/50 py-1 text-center text-[8px] font-medium text-ink-subtle">{d}</div>
))}
{Array.from({ length: 31 }, (_, i) => {
const day = i + 1;
const hasBooking = [3, 7, 12, 15, 18, 22, 24, 28].includes(day);
const isToday = day === 18;
return (
<div
class={`p-1 min-h-[28px] bg-canvas text-center transition-colors ${
isToday ? "bg-accent/15 text-accent font-semibold" : "text-ink-muted"
}`}
>
<span class="text-[9px]">{day}</span>
{hasBooking && (
<div class="flex justify-center mt-0.5">
<div class="w-1 h-1 rounded-full bg-accent" />
</div>
)}
</div>
);
})}
</div>
<div class="mt-auto flex items-center gap-2 text-[9px] text-ink-subtle">
<div class="flex items-center gap-1"><div class="w-1.5 h-1.5 rounded-full bg-accent" /><span>Rezervace</span></div>
<div class="flex items-center gap-1"><div class="w-1.5 h-1.5 rounded-full bg-accent/30" /><span>Dnes</span></div>
</div>
</div>
),
fadeBottom: true,
},
{
name: isCs() ? "Správa zákazníků" : "Customer CRM",
description: isCs()
? "Historie návštěv, poznámky a preference na jednom místě."
: "Visit history, notes, and preferences all in one place.",
href: "/dashboard/customers",
children: (
<div class="w-full h-full flex flex-col gap-2">
<div class="flex items-center gap-2 mb-1">
<div class="flex-1 h-6 rounded-lg border border-border bg-canvas-subtle/40 flex items-center px-2">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-ink-subtle mr-1.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="text-[9px] text-ink-subtle">{isCs() ? "Hledat..." : "Search..."}</span>
</div>
</div>
{[
{ name: "Martina Nováková", email: "martina@example.com", count: 5 },
{ name: "David Svoboda", email: "david@example.com", count: 3 },
{ name: "Jana Kovářová", email: "jana@example.com", count: 8 },
].map((c) => (
<div class="flex items-center gap-2 p-1.5 rounded-lg border border-border/60 bg-canvas-subtle/30">
<div class="w-6 h-6 rounded-full bg-accent-subtle flex items-center justify-center text-[8px] font-bold text-accent shrink-0">
{c.name.split(" ").map((n) => n[0]).join("")}
</div>
<div class="min-w-0 flex-1">
<p class="text-[10px] font-medium text-ink truncate">{c.name}</p>
<p class="text-[8px] text-ink-subtle truncate">{c.email}</p>
</div>
<span class="text-[9px] text-accent font-medium">{c.count}</span>
</div>
))}
</div>
),
fadeBottom: true,
},
{
name: isCs() ? "Analytika a reporty" : "Analytics & Reports",
description: isCs()
? "Podrobné statistiky, exporty a přehledy pro váš podnik."
: "Detailed statistics, exports, and business insights.",
href: "/dashboard?tab=analytics",
children: (
<div class="w-full h-full flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-[10px] font-medium text-ink-muted">{isCs() ? "Příjmy" : "Revenue"}</span>
<span class="text-[9px] text-accent font-semibold">+24 %</span>
</div>
{/* Mini bar chart */}
<div class="flex items-end gap-1 h-16">
{[40, 55, 35, 70, 50, 80, 65].map((h) => (
<div class="flex-1 rounded-t-sm bg-accent/20 hover:bg-accent/40 transition-colors" style={{ height: `${h}%` }} />
))}
</div>
<div class="flex justify-between text-[8px] text-ink-subtle">
<span>Po</span><span>Út</span><span>St</span><span>Čt</span><span></span><span>So</span><span>Ne</span>
</div>
{/* KPI row */}
<div class="mt-auto grid grid-cols-3 gap-2">
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
<p class="text-[9px] font-semibold text-ink">128</p>
<p class="text-[7px] text-ink-subtle">{isCs() ? "Rezervace" : "Bookings"}</p>
</div>
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
<p class="text-[9px] font-semibold text-ink">94 %</p>
<p class="text-[7px] text-ink-subtle">{isCs() ? "Obsazenost" : "Occupancy"}</p>
</div>
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
<p class="text-[9px] font-semibold text-accent">15.2k</p>
<p class="text-[7px] text-ink-subtle">{isCs() ? "Kč" : "$"}</p>
</div>
</div>
</div>
),
fadeBottom: true,
},
]}
/>
</div>
</section>
{/* How it works */}
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
<div class="section-container">
@@ -819,6 +1010,220 @@ export function HomeRoute() {
</div>
</div>
</section>
{/* Video Showcase */}
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-2xl mx-auto text-center mb-12 lg:mb-16">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{isCs() ? "Podívejte se" : "Watch it"}
</span>
<h2 class="text-display-md font-semibold text-ink mb-4 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs() ? "Bookra v akci" : "Bookra in action"}
</h2>
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{isCs()
? "Jak vypadá správa rezervací v praxi — rychlé, přehledné a bez starostí."
: "What booking management looks like in practice — fast, clear, and hassle-free."}
</p>
</div>
<div class="max-w-4xl mx-auto animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<VideoPlayer
src="https://www.pexels.com/fr-fr/download/video/18069166/"
poster="https://images.pexels.com/photos/25626507/pexels-photo-25626507.jpeg"
/>
</div>
</div>
</section>
{/* Live Notifications Mockup */}
<section class="py-20 lg:py-32 overflow-hidden">
<div class="section-container">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<div>
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{isCs() ? "Oznámení v reálném čase" : "Real-time notifications"}
</span>
<h2 class="text-display-md font-semibold text-ink mb-6 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs() ? "Nic vám neunikne" : "Never miss a thing"}
</h2>
<p class="text-lg text-ink-muted mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{isCs()
? "Nové rezervace, připomenutí a upozornění — všechno uvidíte okamžitě."
: "New bookings, reminders, and alerts — everything at a glance."}
</p>
<div class="flex items-center gap-4 animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<A href="/dashboard" class="btn-primary inline-flex items-center gap-2">
{i18n.t("home.cta.primary")}
<ArrowRightIcon />
</A>
</div>
</div>
<div class="animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<div class="surface-elevated rounded-2xl p-5 border border-border/60 shadow-xl max-w-sm mx-auto lg:mx-0 lg:ml-auto">
<div class="flex items-center gap-2 mb-4">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
<BellIcon />
</div>
<span class="font-display font-semibold text-sm text-ink">{isCs() ? "Oznámení" : "Notifications"}</span>
<span class="ml-auto w-2 h-2 rounded-full bg-accent" />
</div>
<AnimatedList
items={[
{ id: "n1", type: "booking", title: isCs() ? "Nová rezervace — Martina N." : "New booking — Martina N.", message: isCs() ? "Masáž — Po 19. května" : "Massage — Mon May 19", time: "2m" },
{ id: "n2", type: "reminder", title: isCs() ? "Připomenutí — David S." : "Reminder — David S.", message: isCs() ? "Fyzio — Za 2 hodiny" : "Physio — In 2 hours", time: "15m" },
{ id: "n3", type: "upgrade", title: isCs() ? "Blížíte se limitu" : "Nearing plan limit", message: isCs() ? "45/50 rezervací" : "45/50 bookings", time: "1h" },
{ id: "n4", type: "booking", title: isCs() ? "Nová rezervace — Jana K." : "New booking — Jana K.", message: isCs() ? "Manikúra — St 21. května" : "Manicure — Wed May 21", time: "3h" },
]}
animation="slide"
gap={8}
renderItem={(n) => (
<div class="flex items-start gap-3 p-3 rounded-xl bg-canvas-subtle/50 border border-border/40">
<div class={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
n.type === "booking" ? "bg-accent-subtle text-accent" :
n.type === "reminder" ? "bg-canvas-muted text-ink-muted" :
"bg-warning-subtle text-warning"
}`}>
{n.type === "booking" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
) : n.type === "reminder" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
)}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{n.title}</p>
<p class="text-xs text-ink-muted">{n.message}</p>
</div>
<span class="text-[10px] text-ink-subtle shrink-0">{n.time}</span>
</div>
)}
/>
</div>
</div>
</div>
</div>
</section>
{/* Mobile Dock Showcase */}
<section class="py-20 lg:py-32 overflow-hidden">
<div class="section-container">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<div class="order-2 lg:order-1 flex justify-center">
<div class="relative">
{/* Phone frame */}
<div class="w-[285px] h-[620px] rounded-[40px] border-[6px] border-canvas-muted bg-canvas shadow-2xl overflow-hidden relative">
{/* Notch */}
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-24 h-6 bg-canvas-muted rounded-b-2xl z-10" />
{/* Screen content */}
<div class="h-full flex flex-col bg-canvas">
{/* Header mock */}
<div class="pt-10 pb-3 px-4 border-b border-border/40">
<div class="flex items-center justify-between">
<div class="w-8 h-8 rounded-full bg-accent/10" />
<span class="font-display font-semibold text-sm">Bookra</span>
<div class="w-8 h-8 rounded-full bg-canvas-subtle" />
</div>
</div>
{/* Scrollable content */}
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div class="h-20 rounded-xl bg-canvas-subtle border border-border/40" />
<div class="h-16 rounded-xl bg-accent-subtle/30 border border-accent/10" />
<div class="h-20 rounded-xl bg-canvas-subtle border border-border/40" />
<div class="h-16 rounded-xl bg-canvas-subtle border border-border/40" />
</div>
{/* FloatingDock at bottom */}
<div class="p-3">
<FloatingDock
menuItems={[
{
id: "profile",
label: isCs() ? "Profil" : "Profile",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
},
{
id: "upgrade",
label: isCs() ? "Upgrade" : "Upgrade",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 1 7.38 16.75"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/></svg>,
},
{
id: "projects",
label: isCs() ? "Projekty" : "Projects",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/><path d="M8 10v4"/><path d="M12 10v2"/><path d="M16 10v6"/></svg>,
},
{
id: "docs",
label: isCs() ? "Dokumentace" : "Documentation",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>,
},
{
id: "logout",
label: isCs() ? "Odhlásit" : "Logout",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>,
variant: "danger",
},
]}
bottomActions={[
{
id: "zap",
label: "Zap",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>,
},
{
id: "stats",
label: "Stats",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 7h6v6"/><path d="m22 7-8.5 8.5-5-5L2 17"/></svg>,
},
{
id: "puzzle",
label: "Apps",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>,
},
{
id: "bell",
label: "Alerts",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>,
},
{
id: "user",
label: isCs() ? "Profil" : "Profile",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
},
]}
activeId="user"
/>
</div>
</div>
</div>
{/* Glow effect */}
<div class="absolute -inset-4 bg-accent/5 rounded-[48px] blur-2xl -z-10" />
</div>
</div>
<div class="order-1 lg:order-2">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{isCs() ? "Mobilní zážitek" : "Mobile Experience"}
</span>
<h2 class="text-display-md font-semibold text-ink mb-6 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{isCs() ? "Všechno v kapse" : "Everything in your pocket"}
</h2>
<p class="text-lg text-ink-muted mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{isCs()
? "Spravujte rezervace, zákazníky a tým odkudkoli. Intuitivní rozhraní navržené pro mobilní použití."
: "Manage bookings, customers, and your team from anywhere. An intuitive interface designed for mobile."}
</p>
<div class="flex items-center gap-4 animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<A href="/dashboard" class="btn-primary inline-flex items-center gap-2">
{i18n.t("home.cta.primary")}
<ArrowRightIcon />
</A>
</div>
</div>
</div>
</div>
</section>
</div>
);
}
+13 -5
View File
@@ -3,6 +3,18 @@ import { For } from "solid-js";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui";
import { BookraCharacter } from "../components/bookra-character";
import { useI18n } from "../providers/i18n-provider";
import { useTheme } from "../providers/theme-provider";
const LegalLogo = () => {
const theme = useTheme();
const isDark = () => theme.resolvedTheme() === "dark";
return (
<div class="relative h-28 mx-auto mb-6">
<img src="/bookra-illustrations/logo_text_vertical.svg" alt="Bookra" class="h-28 w-auto mx-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-90": !isDark() }} />
<img src="/bookra-illustrations/logo_text_vertical_white.svg" alt="Bookra" class="h-28 w-auto mx-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-90": isDark() }} />
</div>
);
};
export function LegalRoute() {
const params = useParams();
@@ -124,11 +136,7 @@ export function LegalRoute() {
<div class="mb-6 flex justify-center">
<BookraCharacter pose={heroPose()} size="xl" animate={true} />
</div>
<img
src="/bookra-illustrations/logo_text_vertical.svg"
alt="Bookra"
class="mx-auto mb-6 h-28 w-auto opacity-90"
/>
<LegalLogo />
<p class="text-sm leading-relaxed text-ink-muted">
{kind() === "terms"
? isCs()
@@ -209,6 +209,31 @@ export function PublicBookingRoute() {
</ul>
</CardContent>
</Card>
{/* Location map */}
<Card class="surface-elevated animate-slide-up overflow-hidden" style={{ "animation-delay": "0.45s" }}>
<CardContent class="p-0">
<div class="p-4 border-b border-border flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>
</svg>
<span class="font-display font-semibold text-ink text-sm">{i18n.locale() === 'cs' ? 'Lokace' : 'Location'}</span>
</div>
<div class="aspect-video w-full bg-canvas-subtle">
<iframe
src="https://www.openstreetmap.org/export/embed.html?bbox=14.3%2C50.0%2C14.6%2C50.1&layer=mapnik&marker=50.05%2C14.45"
class="w-full h-full border-0"
loading="lazy"
title={i18n.locale() === 'cs' ? 'Mapa lokace' : 'Location map'}
/>
</div>
<div class="p-4 text-sm text-ink-muted">
{i18n.locale() === 'cs'
? 'Přesnou adresu zobrazíme po potvrzení rezervace.'
: 'Exact address shown after booking confirmation.'}
</div>
</CardContent>
</Card>
</div>
{/* Main booking card - Slots - ORDER 2 on mobile, 1 on desktop */}
+32
View File
@@ -594,4 +594,36 @@ p {
border-radius: inherit;
background: hsl(var(--canvas) / 0.9);
}
/* Animated list keyframes */
@keyframes list-scale-in {
from { opacity: 0; transform: translateY(-16px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes list-slide-in {
from { opacity: 0; transform: translateY(-24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes list-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes list-bounce-in {
0% { opacity: 0; transform: translateY(-16px) scale(0.85); }
60% { opacity: 1; transform: translateY(4px) scale(1.02); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
.animate-list-scale {
animation: list-scale-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.animate-list-slide {
animation: list-slide-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.animate-list-fade {
animation: list-fade-in 0.35s ease-out both;
}
.animate-list-bounce {
animation: list-bounce-in 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
}
+16
View File
@@ -25,6 +25,22 @@ module.exports = {
DEFAULT: "hsl(var(--border))",
subtle: "hsl(var(--border-subtle))",
},
info: {
DEFAULT: "hsl(var(--info))",
subtle: "hsl(var(--info-subtle))",
},
error: {
DEFAULT: "hsl(var(--error))",
subtle: "hsl(var(--error-subtle))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
subtle: "hsl(var(--warning-subtle))",
},
success: {
DEFAULT: "hsl(var(--success))",
subtle: "hsl(var(--success-subtle))",
},
// Legacy compatibility
mist: "hsl(var(--canvas-muted))",
ember: "hsl(var(--accent))",
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/bookra-character.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 100 KiB

+3
View File
@@ -8,6 +8,9 @@ VITE_APP_URL=https://bookra.eu
# Stripe (Publishable key only - secret key NOT needed)
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51TV6GOGrjyNQaOSGddY1BS14H63e1uYgZUgPe25WhP7yMxmZxMprN3U3scnsmKGBmREzHLrhJlVSEiErimNwVtuR00TRAcHaJi
# Neon Auth
VITE_NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
# Feature Flags
VITE_DEMO_MODE=false
VITE_SHOW_PRICING=true