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.
This commit is contained in:
Tomas Dvorak committed 2026-05-18 14:31:20 +02:00
1 parent 9d63fa7620
commit da5ba13eab
41 files changed
+8761 -184

No files matched your search

@@ -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: "[email protected]", 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: "[email protected]", phone: "+420 987 654 321", bookingsCount: 3, lastBookingAt: new Date(Date.now() - 172800000).toISOString(), status: "active", notes: "" },
{ id: "3", name: "Jana KovarovA", email: "[email protected]", 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: "[email protected]", phone: "+420 111 222 333", bookingsCount: 1, lastBookingAt: new Date(Date.now() - 604800000).toISOString(), status: "inactive", notes: "" },
{ id: "5", name: "Bob Smith", email: "[email protected]", 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: "[email protected]" },
{ name: cs ? "David Svoboda" : "David Svoboda", email: "[email protected]" },
{ name: cs ? "Jana KovarovA" : "Jana KovarovA", email: "[email protected]" },
{ name: cs ? "Alice Johnson" : "Alice Johnson", email: "[email protected]" },
{ name: cs ? "Bob Smith" : "Bob Smith", email: "[email protected]" },
];
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: "[email protected]" }],
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" ? "Po–Ne, svátky" : "Mon–Sun, 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>
);
}