mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-29 16:04:45 +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
@@ -187,8 +187,15 @@ export function DashboardRoute() {
|
||||
const i18n = useI18n();
|
||||
const auth = useAuth();
|
||||
const theme = useTheme();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeSection, setActiveSection] = createSignal<Section>("overview");
|
||||
const isDark = () => theme.resolvedTheme() === "dark";
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialTab = () => {
|
||||
const t = searchParams["tab"];
|
||||
const tab = Array.isArray(t) ? t[0] : t;
|
||||
const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings"];
|
||||
return validTabs.includes(tab as Section) ? (tab as Section) : "overview";
|
||||
};
|
||||
const [activeSection, setActiveSection] = createSignal<Section>(initialTab());
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
|
||||
const [isPageTransitioning, setIsPageTransitioning] = createSignal(false);
|
||||
const [billingNotice, setBillingNotice] = createSignal<string | null>(null);
|
||||
@@ -274,6 +281,7 @@ export function DashboardRoute() {
|
||||
if (section === activeSection()) return;
|
||||
setIsPageTransitioning(true);
|
||||
setTimeout(() => { setActiveSection(section); setIsPageTransitioning(false); }, 150);
|
||||
setSearchParams({ tab: section });
|
||||
};
|
||||
|
||||
const openCheckout = async () => {
|
||||
@@ -317,6 +325,16 @@ export function DashboardRoute() {
|
||||
finally { setBillingAction(null); }
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const t = searchParams["tab"];
|
||||
const tab = Array.isArray(t) ? t[0] : t;
|
||||
const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings"];
|
||||
if (tab && validTabs.includes(tab as Section) && tab !== activeSection()) {
|
||||
setIsPageTransitioning(true);
|
||||
setTimeout(() => { setActiveSection(tab as Section); setIsPageTransitioning(false); }, 150);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const state = Array.isArray(searchParams["billing"]) ? searchParams["billing"][0] : searchParams["billing"];
|
||||
if (state && state !== handledBillingState()) {
|
||||
@@ -380,12 +398,16 @@ export function DashboardRoute() {
|
||||
<aside class="hidden lg:flex flex-col w-64 h-screen bg-canvas border-r border-border sticky top-0 shrink-0">
|
||||
<div class="p-6">
|
||||
<A href="/" class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-colors">
|
||||
<img src="/bookra-logo.svg" alt="Bookra" class="h-8 w-auto dark:invert" />
|
||||
<div class="relative h-8">
|
||||
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
|
||||
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
|
||||
</div>
|
||||
</A>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
<A
|
||||
href={`?tab=${item.id}`}
|
||||
onClick={() => changeSection(item.id)}
|
||||
class={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
|
||||
@@ -396,7 +418,7 @@ export function DashboardRoute() {
|
||||
`}
|
||||
>
|
||||
<item.icon /> {item.label}
|
||||
</button>
|
||||
</A>
|
||||
))}
|
||||
</nav>
|
||||
<div class="p-4 border-t border-border space-y-1">
|
||||
@@ -432,14 +454,18 @@ export function DashboardRoute() {
|
||||
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm transition-opacity" onClick={() => setIsMobileMenuOpen(false)} />
|
||||
<div class="absolute left-0 top-0 h-full w-72 bg-canvas shadow-2xl">
|
||||
<div class="p-4 border-b border-border flex items-center justify-between">
|
||||
<img src="/bookra-logo.svg" alt="Bookra" class="h-8 w-auto dark:invert" />
|
||||
<div class="relative h-8">
|
||||
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
|
||||
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
|
||||
</div>
|
||||
<button onClick={() => setIsMobileMenuOpen(false)} class="p-2 hover:bg-canvas-subtle rounded-xl transition-colors" aria-label={i18n.t("dashboard.close")}>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="p-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
<A
|
||||
href={`?tab=${item.id}`}
|
||||
onClick={() => { changeSection(item.id); setIsMobileMenuOpen(false); }}
|
||||
class={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all
|
||||
@@ -447,7 +473,7 @@ export function DashboardRoute() {
|
||||
`}
|
||||
>
|
||||
<item.icon /> {item.label}
|
||||
</button>
|
||||
</A>
|
||||
))}
|
||||
</nav>
|
||||
<div class="p-4 border-t border-border">
|
||||
@@ -565,7 +591,7 @@ export function DashboardRoute() {
|
||||
</svg>
|
||||
{i18n.t("dashboard.shareManage")}
|
||||
</button>
|
||||
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}</span>
|
||||
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<NotificationDropdown bookings={normalizedAllBookings()} billing={resolvedBilling()} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,7 +722,7 @@ export function DashboardRoute() {
|
||||
const [newBookingTime, setNewBookingTime] = createSignal("");
|
||||
const [newBookingNotes, setNewBookingNotes] = createSignal("");
|
||||
|
||||
const resolvedAllBookings = () => (summary.latest as any)?.allBookings ?? demoData().summary.allBookings ?? [];
|
||||
const resolvedAllBookings = () => (summary.latest as any)?.allBookings ?? (isDemoMode() ? demoData().summary.allBookings : []) ?? [];
|
||||
|
||||
const filteredBookings = createMemo(() => {
|
||||
let bookings = resolvedAllBookings();
|
||||
@@ -1251,17 +1277,18 @@ export function DashboardRoute() {
|
||||
const [activeEmailType, setActiveEmailType] = createSignal("confirmation");
|
||||
const [emailSubject, setEmailSubject] = createSignal("");
|
||||
const [emailBody, setEmailBody] = createSignal("");
|
||||
const [emailSaving, setEmailSaving] = createSignal(false);
|
||||
|
||||
const presetColors = [
|
||||
{ name: i18n.locale() === "cs" ? "Hneda" : "Terracotta", color: "#c25e3a" },
|
||||
{ name: i18n.locale() === "cs" ? "Zelena" : "Sage", color: "#5a7c5a" },
|
||||
{ name: i18n.locale() === "cs" ? "Modra" : "Ocean", color: "#3a6a8a" },
|
||||
{ name: i18n.locale() === "cs" ? "Fialova" : "Plum", color: "#6a4a7a" },
|
||||
{ name: i18n.locale() === "cs" ? "Hnědá" : "Terracotta", color: "#c25e3a" },
|
||||
{ name: i18n.locale() === "cs" ? "Zelená" : "Sage", color: "#5a7c5a" },
|
||||
{ name: i18n.locale() === "cs" ? "Modrá" : "Ocean", color: "#3a6a8a" },
|
||||
{ name: i18n.locale() === "cs" ? "Fialová" : "Plum", color: "#6a4a7a" },
|
||||
];
|
||||
|
||||
const handleSaveBrand = async () => {
|
||||
const bearer = token();
|
||||
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo rezimu nelze ukladat." : "Cannot save in demo mode."); return; }
|
||||
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setBrandSaving(true);
|
||||
try {
|
||||
await (apiClient as any).PUT("/v1/tenants/brand", { headers: { Authorization: `Bearer ${bearer}` }, body: { primaryColor: brandColor() } });
|
||||
@@ -1273,7 +1300,7 @@ export function DashboardRoute() {
|
||||
<div class={`space-y-6 transition-opacity duration-200 ${isPageTransitioning() ? "opacity-0" : "opacity-100"}`}>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{i18n.t("dashboard.settings")}</h1>
|
||||
<p class="text-ink-muted mt-1">{i18n.locale() === "cs" ? "Spravujte svuj podnik, branding a predvolby." : "Manage your business, branding and preferences."}</p>
|
||||
<p class="text-ink-muted mt-1">{i18n.locale() === "cs" ? "Spravujte svůj podnik, branding a předvolby." : "Manage your business, branding and preferences."}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -1304,7 +1331,7 @@ export function DashboardRoute() {
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-canvas font-bold" style={{ background: brandColor() }}>B</div>
|
||||
<div>
|
||||
<p class="font-semibold text-ink">Bookra</p>
|
||||
<p class="text-xs text-ink-muted">{i18n.locale() === "cs" ? "Rezervujte si termin" : "Book an appointment"}</p>
|
||||
<p class="text-xs text-ink-muted">{i18n.locale() === "cs" ? "Rezervujte si termín" : "Book an appointment"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full py-2.5 rounded-lg text-canvas font-medium text-sm" style={{ background: brandColor() }}>{i18n.locale() === "cs" ? "Rezervovat" : "Book Now"}</button>
|
||||
@@ -1321,7 +1348,7 @@ export function DashboardRoute() {
|
||||
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><MailIcon /></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.settings.emailNotifications")}</h3>
|
||||
<p class="text-sm text-ink-muted">{i18n.locale() === "cs" ? "Upravte emaily posilane zakaznikum." : "Customize emails sent to customers."}</p>
|
||||
<p class="text-sm text-ink-muted">{i18n.locale() === "cs" ? "Upravte e-maily posílané zákazníkům." : "Customize emails sent to customers."}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4 overflow-x-auto pb-2">
|
||||
@@ -1337,10 +1364,22 @@ export function DashboardRoute() {
|
||||
))}
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<Input type="text" label={i18n.t("dashboard.settings.emailSubject")} value={emailSubject()} onInput={(e) => setEmailSubject(e.currentTarget.value)} placeholder={activeEmailType() === "confirmation" ? (i18n.locale() === "cs" ? "Potvrzeni rezervace" : "Booking Confirmation") : ""} />
|
||||
<Input type="text" label={i18n.t("dashboard.settings.emailSubject")} value={emailSubject()} onInput={(e) => setEmailSubject(e.currentTarget.value)} placeholder={activeEmailType() === "confirmation" ? (i18n.locale() === "cs" ? "Potvrzení rezervace" : "Booking Confirmation") : ""} />
|
||||
<Textarea label={i18n.t("dashboard.settings.emailBody")} value={emailBody()} onInput={(e) => setEmailBody(e.currentTarget.value)} rows={6} resize="none" placeholder={i18n.locale() === "cs" ? "Obsah emailu..." : "Email body..."} />
|
||||
</div>
|
||||
<button class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium">{i18n.t("dashboard.settings.save")}</button>
|
||||
<button onClick={async () => {
|
||||
const bearer = token();
|
||||
if (!bearer || bearer.startsWith("demo.")) { setDemoNotice(i18n.locale() === "cs" ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setEmailSaving(true);
|
||||
try {
|
||||
await (apiClient as any).PUT("/v1/tenants/email-template", { headers: { Authorization: `Bearer ${bearer}` }, body: { type: activeEmailType(), subject: emailSubject(), body: emailBody() } });
|
||||
setDemoNotice(i18n.locale() === "cs" ? "Šablona uložena." : "Template saved.");
|
||||
} catch { setDemoNotice(i18n.locale() === "cs" ? "Chyba při ukládání." : "Save failed."); }
|
||||
finally { setEmailSaving(false); }
|
||||
}} disabled={emailSaving()} class="mt-4 w-full py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors font-medium disabled:opacity-60 flex items-center justify-center gap-2">
|
||||
<Show when={emailSaving()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
|
||||
{emailSaving() ? i18n.t("dashboard.saving") : i18n.t("dashboard.settings.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1379,7 +1418,10 @@ export function DashboardRoute() {
|
||||
<div class="flex-1 flex flex-col min-h-screen">
|
||||
{/* Mobile Header */}
|
||||
<div class="lg:hidden bg-canvas/80 backdrop-blur-xl border-b border-border p-4 flex items-center justify-between sticky top-0 z-40">
|
||||
<img src="/bookra-logo.svg" alt="Bookra" class="h-8 w-auto dark:invert" />
|
||||
<div class="relative h-8">
|
||||
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
|
||||
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-8 w-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NotificationDropdown bookings={normalizedAllBookings()} billing={resolvedBilling()} />
|
||||
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={i18n.locale() === "cs" ? "Otevrit menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
|
||||
@@ -1410,7 +1452,7 @@ export function DashboardRoute() {
|
||||
<div class="absolute inset-0 bg-accent/20 rounded-full blur-3xl opacity-60" />
|
||||
<BookraCharacter pose="hello" size="xl" animate={true} />
|
||||
</div>
|
||||
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{i18n.locale() === "cs" ? "Vas rezervacni system ceka" : "Your booking system awaits"}</h2>
|
||||
<h2 class="text-3xl lg:text-4xl font-bold text-ink tracking-tight font-display">{i18n.locale() === "cs" ? "Váš rezervační systém čeká" : "Your booking system awaits"}</h2>
|
||||
<p class="text-ink-muted mt-3 text-lg max-w-md mx-auto">{i18n.t("dashboard.welcome.body")}</p>
|
||||
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "sign-in" } }))} class="btn-primary w-full sm:w-auto">
|
||||
@@ -1421,7 +1463,7 @@ export function DashboardRoute() {
|
||||
<SparklesIcon /> {i18n.t("dashboard.tryDemo")}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-ink-muted">{i18n.locale() === "cs" ? "Jeste nemate ucet? " : "No account yet? "}
|
||||
<p class="mt-6 text-sm text-ink-muted">{i18n.locale() === "cs" ? "Ještě nemáte účet? " : "No account yet? "}
|
||||
<button onClick={() => window.dispatchEvent(new CustomEvent("openAuthDialog", { detail: { mode: "register" } }))} class="text-accent hover:text-accent-hover font-medium underline underline-offset-2">{i18n.t("auth.createAccount")}</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1431,7 +1473,7 @@ export function DashboardRoute() {
|
||||
<Show when={token() && (bootstrap.loading || summary.loading || billing.loading)}>
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<BookraCharacter pose="walk" size="lg" animate={true} />
|
||||
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{i18n.locale() === "cs" ? "Nacitani dashboardu..." : "Loading your dashboard..."}</p>
|
||||
<p class="mt-6 text-sm text-ink-muted animate-pulse font-medium">{i18n.locale() === "cs" ? "Načítání dashboardu..." : "Loading your dashboard..."}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Show, createMemo } from "solid-js";
|
||||
import { SparklesIcon, XIcon } from "../../components/dashboard/icons";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
function BillingPage() {
|
||||
const data = useDashboardData();
|
||||
const current = data.resolvedBilling();
|
||||
const entitlements = current?.entitlements;
|
||||
const isTrialing = current?.subscriptionStatus === "trialing";
|
||||
const isActive = current?.subscriptionStatus === "active";
|
||||
const planCode = current?.planCode ?? "starter";
|
||||
const planName = current?.planName ?? "Starter";
|
||||
const trialDays = current?.trialDaysRemaining ?? 0;
|
||||
|
||||
const planFeatures = createMemo(() => {
|
||||
const cs = data.i18n.locale() === "cs";
|
||||
const base = [
|
||||
{ label: data.i18n.t("dashboard.billing.locations"), value: entitlements?.maxLocations ?? 1, limit: entitlements?.maxLocations ?? 1 },
|
||||
{ label: data.i18n.t("dashboard.billing.bookings"), value: entitlements?.maxBookings === -1 ? "\u221e" : (entitlements?.maxBookings ?? 50), limit: entitlements?.maxBookings ?? 50 },
|
||||
{ label: data.i18n.t("dashboard.billing.staff"), value: entitlements?.maxStaff ?? 1, limit: entitlements?.maxStaff ?? 1 },
|
||||
];
|
||||
if (planCode === "pro" || planCode === "business") {
|
||||
base.push({ label: cs ? "Emailove pripominky" : "Email reminders", value: "\u2713", limit: "\u2713" });
|
||||
base.push({ label: cs ? "Analytika" : "Analytics", value: "\u2713", limit: "\u2713" });
|
||||
}
|
||||
if (planCode === "business") {
|
||||
base.push({ label: cs ? "API pristup" : "API access", value: "\u2713", limit: "\u2713" });
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.billing")}</h1>
|
||||
<p class="text-ink-muted mt-1">{data.i18n.t("dashboard.billing.currentPlan")}: {planName}</p>
|
||||
</div>
|
||||
|
||||
{/* Billing notice/error */}
|
||||
<Show when={data.billingNotice()}>
|
||||
<div class="p-4 rounded-xl bg-accent-subtle border border-accent/20 flex items-center justify-between animate-fade-in">
|
||||
<div class="flex items-center gap-3"><SparklesIcon /><p class="text-sm font-medium text-accent">{data.billingNotice()}</p></div>
|
||||
<button onClick={() => data.setBillingNotice(null)} class="p-1.5 hover:bg-accent/10 rounded-lg text-accent"><XIcon /></button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={data.billingError()}>
|
||||
<div class="p-4 rounded-xl bg-canvas-subtle border border-ink/10 flex items-center justify-between animate-fade-in">
|
||||
<p class="text-sm font-medium text-ink">{data.billingError()}</p>
|
||||
<button onClick={() => data.setBillingError(null)} class="p-1.5 hover:bg-canvas-muted rounded-lg text-ink-muted"><XIcon /></button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Current Plan Card */}
|
||||
<div class="surface-card p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-xl font-bold text-ink font-display">{planName}</h3>
|
||||
{isTrialing && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.locale() === "cs" ? `Zkusebni doba (${trialDays} dnu)` : `Trial (${trialDays} days)`}</span>}
|
||||
{isActive && <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">{data.i18n.t("dashboard.confirmed")}</span>}
|
||||
</div>
|
||||
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.billing.period")}: {data.billingInterval() === "monthly" ? (data.i18n.locale() === "cs" ? "Mesicne" : "Monthly") : (data.i18n.locale() === "cs" ? "Rocne" : "Yearly")}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button onClick={() => data.setBillingInterval("monthly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "monthly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Mesicne" : "Monthly"}</button>
|
||||
<button onClick={() => data.setBillingInterval("yearly")} class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${data.billingInterval() === "yearly" ? "bg-accent text-canvas" : "bg-canvas-subtle text-ink-muted hover:text-ink"}`}>{data.i18n.locale() === "cs" ? "Rocne" : "Yearly"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{planFeatures().map((feature: any) => (
|
||||
<div class="p-4 bg-canvas-subtle rounded-xl">
|
||||
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{feature.label}</p>
|
||||
<p class="text-lg font-semibold text-ink">{typeof feature.value === "number" ? `${feature.value} / ${feature.limit}` : feature.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-6">
|
||||
<button onClick={() => void data.openCheckout()} disabled={data.billingAction() === "checkout"} class="btn-primary disabled:opacity-50">
|
||||
{data.billingAction() === "checkout" ? (data.i18n.locale() === "cs" ? "Otevirani..." : "Opening...") : (isTrialing ? data.i18n.t("dashboard.upgrade") : data.i18n.t("dashboard.checkout"))}
|
||||
</button>
|
||||
<Show when={isActive || isTrialing}>
|
||||
<button onClick={() => void data.openPortal()} class="btn-secondary">{data.i18n.locale() === "cs" ? "Spravovat predplatne" : "Manage subscription"}</button>
|
||||
</Show>
|
||||
<button onClick={() => void data.refreshBilling()} disabled={data.billingAction() === "refresh"} class="btn-secondary disabled:opacity-50">
|
||||
{data.billingAction() === "refresh" ? (data.i18n.locale() === "cs" ? "Obnovovani..." : "Refreshing...") : data.i18n.t("dashboard.refreshBilling")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BillingRoute() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<BillingPage />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Show, createSignal, createMemo } from "solid-js";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { getInitials } from "../../components/dashboard/types";
|
||||
import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons";
|
||||
import { apiClient } from "../../lib/api-client";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
function BookingsPage() {
|
||||
const data = useDashboardData();
|
||||
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
|
||||
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [showNewBooking, setShowNewBooking] = createSignal(false);
|
||||
const [creatingBooking, setCreatingBooking] = createSignal(false);
|
||||
const [pinnedBookingIds, setPinnedBookingIds] = createSignal<Set<string>>(new Set());
|
||||
|
||||
const [newBookingCustomer, setNewBookingCustomer] = createSignal("");
|
||||
const [newBookingEmail, setNewBookingEmail] = createSignal("");
|
||||
const [newBookingService, setNewBookingService] = createSignal("");
|
||||
const [newBookingDate, setNewBookingDate] = createSignal("");
|
||||
const [newBookingTime, setNewBookingTime] = createSignal("");
|
||||
const [newBookingNotes, setNewBookingNotes] = createSignal("");
|
||||
|
||||
const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? [];
|
||||
|
||||
const filteredBookings = createMemo(() => {
|
||||
let bookings = resolvedAllBookings();
|
||||
if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus());
|
||||
const now = new Date();
|
||||
if (filterDateRange() === "today") bookings = bookings.filter((b: any) => new Date(b.startsAt).toDateString() === now.toDateString());
|
||||
else if (filterDateRange() === "week") {
|
||||
const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= weekEnd; });
|
||||
} else if (filterDateRange() === "month") {
|
||||
const monthEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= monthEnd; });
|
||||
}
|
||||
if (searchQuery().trim()) {
|
||||
const q = searchQuery().toLowerCase();
|
||||
bookings = bookings.filter((b: any) =>
|
||||
b.customerName?.toLowerCase().includes(q) ||
|
||||
b.service?.toLowerCase().includes(q) ||
|
||||
b.reference?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return bookings.sort((a: any, b: any) => {
|
||||
const aPinned = pinnedBookingIds().has(a.id) ? 1 : 0;
|
||||
const bPinned = pinnedBookingIds().has(b.id) ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
return new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
const bookingStats = createMemo(() => {
|
||||
const bookings = resolvedAllBookings();
|
||||
return {
|
||||
total: bookings.length,
|
||||
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
|
||||
pending: bookings.filter((b: any) => b.status === "pending").length,
|
||||
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
|
||||
completed: bookings.filter((b: any) => b.status === "completed").length,
|
||||
};
|
||||
});
|
||||
|
||||
const handleCreateBooking = async () => {
|
||||
const bearer = data.token();
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo rezimu nelze vytvaret rezervace." : "Cannot create bookings in demo mode.");
|
||||
setShowNewBooking(false); return;
|
||||
}
|
||||
setCreatingBooking(true);
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/catalog/bookings", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
body: {
|
||||
customerName: newBookingCustomer(), customerEmail: newBookingEmail(), service: newBookingService(),
|
||||
startsAt: new Date(`${newBookingDate()}T${newBookingTime()}`).toISOString(), notes: newBookingNotes()
|
||||
}
|
||||
});
|
||||
setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes("");
|
||||
setShowNewBooking(false);
|
||||
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoreni rezervace selhalo." : "Failed to create booking."); }
|
||||
finally { setCreatingBooking(false); }
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
|
||||
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.bookingManagement")}</h1>
|
||||
<p class="text-ink-muted mt-1">{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm shrink-0">
|
||||
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
|
||||
{ key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending },
|
||||
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
|
||||
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
|
||||
].map((s) => (
|
||||
<button onClick={() => setFilterStatus(s.key as any)} class={`surface-card p-4 text-left transition-all hover:shadow-md ${filterStatus() === s.key ? "ring-2 ring-accent" : ""}`}>
|
||||
<p class="text-xs text-ink-muted uppercase tracking-wider">{s.label}</p>
|
||||
<p class="text-2xl font-bold text-ink mt-1 font-display">{s.value}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<Input type="text" placeholder={data.i18n.t("dashboard.search.bookings")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select value={filterStatus()} onChange={(v) => setFilterStatus(v as any)} options={[
|
||||
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
|
||||
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
|
||||
{ value: "pending", label: data.i18n.t("dashboard.pending") },
|
||||
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
|
||||
{ value: "completed", label: data.i18n.t("dashboard.completed") },
|
||||
]} />
|
||||
<Select value={filterDateRange()} onChange={(v) => setFilterDateRange(v as any)} options={[
|
||||
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
|
||||
{ value: "today", label: data.i18n.t("dashboard.filter.today") },
|
||||
{ value: "week", label: data.i18n.t("dashboard.filter.week") },
|
||||
{ value: "month", label: data.i18n.t("dashboard.filter.month") },
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookings List */}
|
||||
<div class="surface-card overflow-hidden">
|
||||
<Show when={filteredBookings().length > 0} fallback={
|
||||
<div class="p-12 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4">
|
||||
<CalendarDaysIcon />
|
||||
</div>
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
|
||||
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.empty.bookingsDesc")}</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="divide-y divide-border/60">
|
||||
{filteredBookings().map((booking: any) => {
|
||||
const isPinned = () => pinnedBookingIds().has(booking.id);
|
||||
const togglePin = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
setPinnedBookingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(booking.id)) next.delete(booking.id);
|
||||
else next.add(booking.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div class={`flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors group ${isPinned() ? "bg-accent-soft/40" : ""}`}>
|
||||
<div class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
|
||||
booking.status === "confirmed" ? "bg-accent text-canvas" : "bg-canvas-muted text-ink-muted"
|
||||
}`}>
|
||||
{getInitials(booking.customerName)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium text-ink">{booking.customerName}</p>
|
||||
{isPinned() && <span class="w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
</div>
|
||||
<p class="text-sm text-ink-muted">{booking.service} • {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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { createSignal, onMount, createMemo, Show, For } from "solid-js";
|
||||
import { useI18n } from "../providers/i18n-provider";
|
||||
import { useTheme } from "../providers/theme-provider";
|
||||
import { BookraCharacter } from "../components/bookra-character";
|
||||
import { HoverFeatureCards } from "../components/hover-feature-cards";
|
||||
import { DashboardMockup } from "../components/dashboard-mockup";
|
||||
import { VideoPlayer } from "../components/video-player";
|
||||
import { AnimatedList } from "../components/animated-list";
|
||||
import { FloatingDock } from "../components/floating-dock";
|
||||
|
||||
const HomeLogo = () => {
|
||||
const theme = useTheme();
|
||||
const isDark = () => theme.resolvedTheme() === "dark";
|
||||
return (
|
||||
<div class="relative h-9">
|
||||
<img src="/bookra-illustrations/logo_text_horizontal.svg" alt="Bookra" class="h-9 w-auto opacity-75 absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-75": !isDark() }} />
|
||||
<img src="/bookra-illustrations/logo_text_horizontal_white.svg" alt="Bookra" class="h-9 w-auto opacity-75 absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-75": isDark() }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Lucide-like icon components (lightweight SVG)
|
||||
const CalendarIcon = () => (
|
||||
@@ -453,11 +470,7 @@ export function HomeRoute() {
|
||||
<section class="py-12 border-y border-border/50 bg-canvas-subtle/50">
|
||||
<div class="section-container">
|
||||
<div class="mb-6 flex justify-center">
|
||||
<img
|
||||
src="/bookra-illustrations/logo_text_horizontal.svg"
|
||||
alt="Bookra"
|
||||
class="h-9 w-auto opacity-75"
|
||||
/>
|
||||
<HomeLogo />
|
||||
</div>
|
||||
<p class="text-center text-sm text-ink-subtle mb-8 tracking-wide uppercase">
|
||||
{i18n.t("home.trust")}
|
||||
@@ -503,6 +516,144 @@ export function HomeRoute() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Product Showcase — Hover Feature Cards */}
|
||||
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
|
||||
<div class="section-container">
|
||||
<div class="max-w-2xl mx-auto text-center mb-16 lg:mb-20">
|
||||
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
|
||||
{isCs() ? "Prozkoumejte" : "Explore"}
|
||||
</span>
|
||||
<h2 class="text-display-md font-semibold text-ink mb-4 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
|
||||
{isCs() ? "Vše, co potřebujete, na jednom místě" : "Everything you need in one place"}
|
||||
</h2>
|
||||
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.2s" }}>
|
||||
{isCs()
|
||||
? "Náhled do aplikace — rezervace, zákazníci, analytika i nastavení."
|
||||
: "A peek inside the app — bookings, customers, analytics, and settings."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HoverFeatureCards
|
||||
items={[
|
||||
{
|
||||
name: isCs() ? "Přehledný dashboard" : "Clean Dashboard",
|
||||
description: isCs()
|
||||
? "Sledujte KPI, trendy rezervací a dnešní agenda v reálném čase."
|
||||
: "Track KPIs, booking trends, and today's agenda in real time.",
|
||||
href: "/dashboard",
|
||||
children: <DashboardMockup />,
|
||||
fadeBottom: true,
|
||||
},
|
||||
{
|
||||
name: isCs() ? "Kalendář rezervací" : "Booking Calendar",
|
||||
description: isCs()
|
||||
? "Intuitivní týdenní a měsíční pohled s drag-and-drop plánováním."
|
||||
: "Intuitive weekly and monthly views with drag-and-drop scheduling.",
|
||||
href: "/dashboard/bookings",
|
||||
children: (
|
||||
<div class="w-full h-full flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-medium text-ink-muted">Květen 2026</span>
|
||||
<div class="flex gap-1">
|
||||
<div class="w-5 h-5 rounded border border-border flex items-center justify-center text-ink-subtle">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</div>
|
||||
<div class="w-5 h-5 rounded border border-border flex items-center justify-center text-ink-subtle">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-px bg-border/40 rounded-lg overflow-hidden">
|
||||
{["Po","Út","St","Čt","Pá","So","Ne"].map((d) => (
|
||||
<div class="bg-canvas-subtle/50 py-1 text-center text-[8px] font-medium text-ink-subtle">{d}</div>
|
||||
))}
|
||||
{Array.from({ length: 31 }, (_, i) => {
|
||||
const day = i + 1;
|
||||
const hasBooking = [3, 7, 12, 15, 18, 22, 24, 28].includes(day);
|
||||
const isToday = day === 18;
|
||||
return (
|
||||
<div
|
||||
class={`p-1 min-h-[28px] bg-canvas text-center transition-colors ${
|
||||
isToday ? "bg-accent/15 text-accent font-semibold" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
<span class="text-[9px]">{day}</span>
|
||||
{hasBooking && (
|
||||
<div class="flex justify-center mt-0.5">
|
||||
<div class="w-1 h-1 rounded-full bg-accent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div class="mt-auto flex items-center gap-2 text-[9px] text-ink-subtle">
|
||||
<div class="flex items-center gap-1"><div class="w-1.5 h-1.5 rounded-full bg-accent" /><span>Rezervace</span></div>
|
||||
<div class="flex items-center gap-1"><div class="w-1.5 h-1.5 rounded-full bg-accent/30" /><span>Dnes</span></div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
fadeBottom: true,
|
||||
},
|
||||
{
|
||||
name: isCs() ? "Správa zákazníků" : "Customer CRM",
|
||||
description: isCs()
|
||||
? "Historie návštěv, poznámky a preference na jednom místě."
|
||||
: "Visit history, notes, and preferences all in one place.",
|
||||
href: "/dashboard/customers",
|
||||
children: (
|
||||
<div class="w-full h-full flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="flex-1 h-6 rounded-lg border border-border bg-canvas-subtle/40 flex items-center px-2">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-ink-subtle mr-1.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<span class="text-[9px] text-ink-subtle">{isCs() ? "Hledat..." : "Search..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
{[
|
||||
{ name: "Martina Nováková", email: "[email protected]", count: 5 },
|
||||
{ name: "David Svoboda", email: "[email protected]", count: 3 },
|
||||
{ name: "Jana Kovářová", email: "[email protected]", count: 8 },
|
||||
].map((c) => (
|
||||
<div class="flex items-center gap-2 p-1.5 rounded-lg border border-border/60 bg-canvas-subtle/30">
|
||||
<div class="w-6 h-6 rounded-full bg-accent-subtle flex items-center justify-center text-[8px] font-bold text-accent shrink-0">
|
||||
{c.name.split(" ").map((n) => n[0]).join("")}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] font-medium text-ink truncate">{c.name}</p>
|
||||
<p class="text-[8px] text-ink-subtle truncate">{c.email}</p>
|
||||
</div>
|
||||
<span class="text-[9px] text-accent font-medium">{c.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
fadeBottom: true,
|
||||
},
|
||||
{
|
||||
name: isCs() ? "Analytika a reporty" : "Analytics & Reports",
|
||||
description: isCs()
|
||||
? "Podrobné statistiky, exporty a přehledy pro váš podnik."
|
||||
: "Detailed statistics, exports, and business insights.",
|
||||
soon: true,
|
||||
children: (
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-2">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-ink-subtle">
|
||||
<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-[10px] text-ink-subtle">{isCs() ? "Brzy dostupné" : "Coming soon"}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
containerClassName: "items-center justify-center",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
|
||||
<div class="section-container">
|
||||
@@ -819,6 +970,273 @@ export function HomeRoute() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Video Showcase */}
|
||||
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
|
||||
<div class="section-container">
|
||||
<div class="max-w-2xl mx-auto text-center mb-12 lg:mb-16">
|
||||
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
|
||||
{isCs() ? "Podívejte se" : "Watch it"}
|
||||
</span>
|
||||
<h2 class="text-display-md font-semibold text-ink mb-4 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
|
||||
{isCs() ? "Bookra v akci" : "Bookra in action"}
|
||||
</h2>
|
||||
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.2s" }}>
|
||||
{isCs()
|
||||
? "Jak vypadá správa rezervací v praxi — rychlé, přehledné a bez starostí."
|
||||
: "What booking management looks like in practice — fast, clear, and hassle-free."}
|
||||
</p>
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto animate-slide-up" style={{ "animation-delay": "0.3s" }}>
|
||||
<VideoPlayer
|
||||
src="https://www.pexels.com/fr-fr/download/video/18069166/"
|
||||
poster="https://images.pexels.com/photos/25626507/pexels-photo-25626507.jpeg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Live Notifications Mockup */}
|
||||
<section class="py-20 lg:py-32 overflow-hidden">
|
||||
<div class="section-container">
|
||||
<div class="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
|
||||
{isCs() ? "Oznámení v reálném čase" : "Real-time notifications"}
|
||||
</span>
|
||||
<h2 class="text-display-md font-semibold text-ink mb-6 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
|
||||
{isCs() ? "Nic vám neunikne" : "Never miss a thing"}
|
||||
</h2>
|
||||
<p class="text-lg text-ink-muted mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
|
||||
{isCs()
|
||||
? "Nové rezervace, připomenutí a upozornění — všechno uvidíte okamžitě."
|
||||
: "New bookings, reminders, and alerts — everything at a glance."}
|
||||
</p>
|
||||
<div class="flex items-center gap-4 animate-slide-up" style={{ "animation-delay": "0.3s" }}>
|
||||
<A href="/dashboard" class="btn-primary inline-flex items-center gap-2">
|
||||
{i18n.t("home.cta.primary")}
|
||||
<ArrowRightIcon />
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
<div class="animate-slide-up" style={{ "animation-delay": "0.3s" }}>
|
||||
<div class="surface-elevated rounded-2xl p-5 border border-border/60 shadow-xl max-w-sm mx-auto lg:mx-0 lg:ml-auto">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||
<BellIcon />
|
||||
</div>
|
||||
<span class="font-display font-semibold text-sm text-ink">{isCs() ? "Oznámení" : "Notifications"}</span>
|
||||
<span class="ml-auto w-2 h-2 rounded-full bg-accent" />
|
||||
</div>
|
||||
<AnimatedList
|
||||
items={[
|
||||
{ id: "n1", type: "booking", title: isCs() ? "Nová rezervace — Martina N." : "New booking — Martina N.", message: isCs() ? "Masáž — Po 19. května" : "Massage — Mon May 19", time: "2m" },
|
||||
{ id: "n2", type: "reminder", title: isCs() ? "Připomenutí — David S." : "Reminder — David S.", message: isCs() ? "Fyzio — Za 2 hodiny" : "Physio — In 2 hours", time: "15m" },
|
||||
{ id: "n3", type: "upgrade", title: isCs() ? "Blížíte se limitu" : "Nearing plan limit", message: isCs() ? "45/50 rezervací" : "45/50 bookings", time: "1h" },
|
||||
{ id: "n4", type: "booking", title: isCs() ? "Nová rezervace — Jana K." : "New booking — Jana K.", message: isCs() ? "Manikúra — St 21. května" : "Manicure — Wed May 21", time: "3h" },
|
||||
]}
|
||||
animation="slide"
|
||||
gap={8}
|
||||
renderItem={(n) => (
|
||||
<div class="flex items-start gap-3 p-3 rounded-xl bg-canvas-subtle/50 border border-border/40">
|
||||
<div class={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
||||
n.type === "booking" ? "bg-accent-subtle text-accent" :
|
||||
n.type === "reminder" ? "bg-canvas-muted text-ink-muted" :
|
||||
"bg-warning-subtle text-warning"
|
||||
}`}>
|
||||
{n.type === "booking" ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||
) : n.type === "reminder" ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-ink truncate">{n.title}</p>
|
||||
<p class="text-xs text-ink-muted">{n.message}</p>
|
||||
</div>
|
||||
<span class="text-[10px] text-ink-subtle shrink-0">{n.time}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mobile Dock Showcase */}
|
||||
<section class="py-20 lg:py-32 overflow-hidden">
|
||||
<div class="section-container">
|
||||
<div class="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
<div class="order-2 lg:order-1 flex justify-center">
|
||||
<div class="relative">
|
||||
{/* Phone frame */}
|
||||
<div class="w-[285px] h-[560px] rounded-[40px] border-[6px] border-canvas-muted bg-canvas shadow-2xl overflow-hidden relative">
|
||||
{/* Notch */}
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-24 h-6 bg-canvas-muted rounded-b-2xl z-10" />
|
||||
{/* Screen content */}
|
||||
<div class="h-full flex flex-col bg-canvas">
|
||||
{/* Header mock */}
|
||||
<div class="pt-10 pb-3 px-4 border-b border-border/40">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-8 h-8 rounded-full bg-accent/10" />
|
||||
<span class="font-display font-semibold text-sm">Bookra</span>
|
||||
<div class="w-8 h-8 rounded-full bg-canvas-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Scrollable content */}
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div class="h-20 rounded-xl bg-canvas-subtle border border-border/40" />
|
||||
<div class="h-16 rounded-xl bg-accent-subtle/30 border border-accent/10" />
|
||||
<div class="h-20 rounded-xl bg-canvas-subtle border border-border/40" />
|
||||
<div class="h-16 rounded-xl bg-canvas-subtle border border-border/40" />
|
||||
</div>
|
||||
{/* FloatingDock at bottom */}
|
||||
<div class="p-3">
|
||||
<FloatingDock
|
||||
menuItems={[
|
||||
{
|
||||
id: "profile",
|
||||
label: isCs() ? "Profil" : "Profile",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
|
||||
},
|
||||
{
|
||||
id: "upgrade",
|
||||
label: isCs() ? "Upgrade" : "Upgrade",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 1 7.38 16.75"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/></svg>,
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
label: isCs() ? "Projekty" : "Projects",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/><path d="M8 10v4"/><path d="M12 10v2"/><path d="M16 10v6"/></svg>,
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
label: isCs() ? "Dokumentace" : "Documentation",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>,
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
label: isCs() ? "Odhlásit" : "Logout",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>,
|
||||
variant: "danger",
|
||||
},
|
||||
]}
|
||||
bottomActions={[
|
||||
{
|
||||
id: "zap",
|
||||
label: "Zap",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>,
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
label: "Stats",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 7h6v6"/><path d="m22 7-8.5 8.5-5-5L2 17"/></svg>,
|
||||
},
|
||||
{
|
||||
id: "puzzle",
|
||||
label: "Apps",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>,
|
||||
},
|
||||
{
|
||||
id: "bell",
|
||||
label: "Alerts",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>,
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
label: isCs() ? "Profil" : "Profile",
|
||||
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
|
||||
},
|
||||
]}
|
||||
activeId="user"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Glow effect */}
|
||||
<div class="absolute -inset-4 bg-accent/5 rounded-[48px] blur-2xl -z-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-1 lg:order-2">
|
||||
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
|
||||
{isCs() ? "Mobilní zážitek" : "Mobile Experience"}
|
||||
</span>
|
||||
<h2 class="text-display-md font-semibold text-ink mb-6 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
|
||||
{isCs() ? "Všechno v kapse" : "Everything in your pocket"}
|
||||
</h2>
|
||||
<p class="text-lg text-ink-muted mb-8 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
|
||||
{isCs()
|
||||
? "Spravujte rezervace, zákazníky a tým odkudkoli. Intuitivní rozhraní navržené pro mobilní použití."
|
||||
: "Manage bookings, customers, and your team from anywhere. An intuitive interface designed for mobile."}
|
||||
</p>
|
||||
<div class="flex items-center gap-4 animate-slide-up" style={{ "animation-delay": "0.3s" }}>
|
||||
<A href="/dashboard" class="btn-primary inline-flex items-center gap-2">
|
||||
{i18n.t("home.cta.primary")}
|
||||
<ArrowRightIcon />
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer class="border-t border-border/50 bg-canvas-subtle/30">
|
||||
<div class="section-container py-12">
|
||||
<div class="grid md:grid-cols-3 gap-8 mb-10">
|
||||
<div>
|
||||
<HomeLogo />
|
||||
<p class="mt-4 text-sm text-ink-muted leading-relaxed max-w-xs">
|
||||
{i18n.t("footer.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-display font-semibold text-ink mb-4 text-sm">{i18n.t("footer.links.title")}</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><A href="/pricing" class="text-sm text-ink-muted hover:text-accent transition-colors">{i18n.t("nav.pricing")}</A></li>
|
||||
<li><A href="/about" class="text-sm text-ink-muted hover:text-accent transition-colors">{i18n.t("nav.about")}</A></li>
|
||||
<li><A href="/contact" class="text-sm text-ink-muted hover:text-accent transition-colors">{i18n.t("nav.contact")}</A></li>
|
||||
<li><A href="/dashboard" class="text-sm text-ink-muted hover:text-accent transition-colors">{i18n.t("nav.dashboard")}</A></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-display font-semibold text-ink mb-4 text-sm">{i18n.t("footer.legal.title")}</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><A href="/privacy" class="text-sm text-ink-muted hover:text-accent transition-colors">{i18n.t("footer.privacy")}</A></li>
|
||||
<li><A href="/terms" class="text-sm text-ink-muted hover:text-accent transition-colors">{i18n.t("footer.terms")}</A></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 pt-6 border-t border-border/50">
|
||||
<p class="text-xs text-ink-subtle">{i18n.t("footer.copyright")}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="https://facebook.com/bookra.eu" target="_blank" rel="noopener noreferrer" aria-label="Facebook" class="w-9 h-9 rounded-lg bg-canvas-subtle flex items-center justify-center text-ink-muted hover:text-accent hover:bg-accent-subtle transition-all">
|
||||
<FacebookIcon />
|
||||
</a>
|
||||
<a href="https://instagram.com/bookra.eu" target="_blank" rel="noopener noreferrer" aria-label="Instagram" class="w-9 h-9 rounded-lg bg-canvas-subtle flex items-center justify-center text-ink-muted hover:text-accent hover:bg-accent-subtle transition-all">
|
||||
<InstagramIcon />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FacebookIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const InstagramIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/>
|
||||
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/>
|
||||
<line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>
|
||||
</svg>
|
||||
);
|
||||
@@ -3,6 +3,18 @@ import { For } from "solid-js";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui";
|
||||
import { BookraCharacter } from "../components/bookra-character";
|
||||
import { useI18n } from "../providers/i18n-provider";
|
||||
import { useTheme } from "../providers/theme-provider";
|
||||
|
||||
const LegalLogo = () => {
|
||||
const theme = useTheme();
|
||||
const isDark = () => theme.resolvedTheme() === "dark";
|
||||
return (
|
||||
<div class="relative h-28 mx-auto mb-6">
|
||||
<img src="/bookra-illustrations/logo_text_vertical.svg" alt="Bookra" class="h-28 w-auto mx-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": isDark(), "opacity-90": !isDark() }} />
|
||||
<img src="/bookra-illustrations/logo_text_vertical_white.svg" alt="Bookra" class="h-28 w-auto mx-auto absolute inset-0 transition-opacity duration-200" classList={{ "opacity-0": !isDark(), "opacity-90": isDark() }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function LegalRoute() {
|
||||
const params = useParams();
|
||||
@@ -124,11 +136,7 @@ export function LegalRoute() {
|
||||
<div class="mb-6 flex justify-center">
|
||||
<BookraCharacter pose={heroPose()} size="xl" animate={true} />
|
||||
</div>
|
||||
<img
|
||||
src="/bookra-illustrations/logo_text_vertical.svg"
|
||||
alt="Bookra"
|
||||
class="mx-auto mb-6 h-28 w-auto opacity-90"
|
||||
/>
|
||||
<LegalLogo />
|
||||
<p class="text-sm leading-relaxed text-ink-muted">
|
||||
{kind() === "terms"
|
||||
? isCs()
|
||||
|
||||
@@ -209,6 +209,31 @@ export function PublicBookingRoute() {
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Location map */}
|
||||
<Card class="surface-elevated animate-slide-up overflow-hidden" style={{ "animation-delay": "0.45s" }}>
|
||||
<CardContent class="p-0">
|
||||
<div class="p-4 border-b border-border flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
<span class="font-display font-semibold text-ink text-sm">{i18n.locale() === 'cs' ? 'Lokace' : 'Location'}</span>
|
||||
</div>
|
||||
<div class="aspect-video w-full bg-canvas-subtle">
|
||||
<iframe
|
||||
src="https://www.openstreetmap.org/export/embed.html?bbox=14.3%2C50.0%2C14.6%2C50.1&layer=mapnik&marker=50.05%2C14.45"
|
||||
class="w-full h-full border-0"
|
||||
loading="lazy"
|
||||
title={i18n.locale() === 'cs' ? 'Mapa lokace' : 'Location map'}
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4 text-sm text-ink-muted">
|
||||
{i18n.locale() === 'cs'
|
||||
? 'Přesnou adresu zobrazíme po potvrzení rezervace.'
|
||||
: 'Exact address shown after booking confirmation.'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main booking card - Slots - ORDER 2 on mobile, 1 on desktop */}
|
||||
|
||||
Reference in new issue
Block a user