mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
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} • {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 — {(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} • {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)} • {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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user