mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 20:43:01 +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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user