mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
feat(ui): add analytics dashboard and enhance frontend components
- Implement new analytics section in the dashboard with KPI cards and charts - Add video player scrubber and playback controls - Improve FloatingDock layout and responsiveness - Enhance HomeRoute with demo mode redirection and improved trust section UI - Update dashboard routing to support the new analytics tab
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import type { JSX } from "solid-js";
|
import type { JSX } from "solid-js";
|
||||||
|
|
||||||
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
|
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings" | "analytics";
|
||||||
export type BookingStatus = "confirmed" | "pending" | "cancelled";
|
export type BookingStatus = "confirmed" | "pending" | "cancelled";
|
||||||
|
|
||||||
export interface KpiData {
|
export interface KpiData {
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export interface FloatingDockProps {
|
|||||||
|
|
||||||
export function FloatingDock(props: FloatingDockProps) {
|
export function FloatingDock(props: FloatingDockProps) {
|
||||||
return (
|
return (
|
||||||
<div class={`relative overflow-hidden rounded-3xl bg-canvas border border-border shadow-xl ${props.className ?? ""}`}>
|
<div class={`flex flex-col overflow-hidden rounded-3xl bg-canvas border border-border shadow-xl ${props.className ?? ""}`}>
|
||||||
{/* Menu items */}
|
{/* Menu items */}
|
||||||
<div class="p-4 pb-2">
|
<div class="flex-1 overflow-y-auto p-4 pb-2">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
{props.menuItems.map((item) => {
|
{props.menuItems.map((item) => {
|
||||||
const content = (
|
const content = (
|
||||||
@@ -56,7 +56,7 @@ export function FloatingDock(props: FloatingDockProps) {
|
|||||||
|
|
||||||
{/* Bottom action bar */}
|
{/* Bottom action bar */}
|
||||||
<Show when={props.bottomActions && props.bottomActions.length > 0}>
|
<Show when={props.bottomActions && props.bottomActions.length > 0}>
|
||||||
<div class="absolute bottom-0 w-full p-2">
|
<div class="shrink-0 border-t border-border/60 p-2">
|
||||||
<div class="flex h-9 w-full items-center justify-center gap-1">
|
<div class="flex h-9 w-full items-center justify-center gap-1">
|
||||||
{props.bottomActions!.map((action) => (
|
{props.bottomActions!.map((action) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Show, onMount, onCleanup } from "solid-js";
|
import { createSignal, Show, onMount, onCleanup, createMemo } from "solid-js";
|
||||||
|
|
||||||
const CANVAS_W = 64;
|
const CANVAS_W = 64;
|
||||||
const CANVAS_H = 36;
|
const CANVAS_H = 36;
|
||||||
@@ -11,15 +11,31 @@ export interface VideoPlayerProps {
|
|||||||
intensity?: number;
|
intensity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function VideoPlayer(props: VideoPlayerProps) {
|
export function VideoPlayer(props: VideoPlayerProps) {
|
||||||
const blurAmount = () => props.blurAmount ?? 60;
|
const blurAmount = () => props.blurAmount ?? 60;
|
||||||
const intensity = () => props.intensity ?? 0.85;
|
const intensity = () => props.intensity ?? 0.85;
|
||||||
|
|
||||||
const [playing, setPlaying] = createSignal(false);
|
const [playing, setPlaying] = createSignal(false);
|
||||||
|
const [currentTime, setCurrentTime] = createSignal(0);
|
||||||
|
const [duration, setDuration] = createSignal(0);
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
let videoRef: HTMLVideoElement | undefined;
|
let videoRef: HTMLVideoElement | undefined;
|
||||||
let canvasRef: HTMLCanvasElement | undefined;
|
let canvasRef: HTMLCanvasElement | undefined;
|
||||||
|
let trackRef: HTMLDivElement | undefined;
|
||||||
let rafId: number;
|
let rafId: number;
|
||||||
|
|
||||||
|
const progress = createMemo(() => {
|
||||||
|
const dur = duration();
|
||||||
|
if (!dur) return 0;
|
||||||
|
return (currentTime() / dur) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const video = videoRef;
|
const video = videoRef;
|
||||||
const canvas = canvasRef;
|
const canvas = canvasRef;
|
||||||
@@ -59,6 +75,58 @@ export function VideoPlayer(props: VideoPlayerProps) {
|
|||||||
|
|
||||||
const onVideoEnded = () => setPlaying(false);
|
const onVideoEnded = () => setPlaying(false);
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
if (videoRef && !isDragging()) {
|
||||||
|
setCurrentTime(videoRef.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
if (videoRef) {
|
||||||
|
setDuration(videoRef.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekTo = (clientX: number) => {
|
||||||
|
const track = trackRef;
|
||||||
|
const video = videoRef;
|
||||||
|
if (!track || !video) return;
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const pct = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
video.currentTime = pct * duration();
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTrackMouseDown = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
seekTo(e.clientX);
|
||||||
|
|
||||||
|
const onMouseMove = (ev: MouseEvent) => seekTo(ev.clientX);
|
||||||
|
const onMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTrackTouchStart = (e: TouchEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
seekTo(e.touches[0].clientX);
|
||||||
|
|
||||||
|
const onTouchMove = (ev: TouchEvent) => seekTo(ev.touches[0].clientX);
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
window.removeEventListener("touchmove", onTouchMove);
|
||||||
|
window.removeEventListener("touchend", onTouchEnd);
|
||||||
|
};
|
||||||
|
window.addEventListener("touchmove", onTouchMove);
|
||||||
|
window.addEventListener("touchend", onTouchEnd);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`relative rounded-2xl overflow-hidden bg-canvas border border-border/60 shadow-lg ${props.className ?? ""}`}>
|
<div class={`relative rounded-2xl overflow-hidden bg-canvas border border-border/60 shadow-lg ${props.className ?? ""}`}>
|
||||||
{/* Ambient glow canvas */}
|
{/* Ambient glow canvas */}
|
||||||
@@ -90,6 +158,8 @@ export function VideoPlayer(props: VideoPlayerProps) {
|
|||||||
class="w-full block bg-canvas rounded-2xl"
|
class="w-full block bg-canvas rounded-2xl"
|
||||||
onEnded={onVideoEnded}
|
onEnded={onVideoEnded}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onLoadedMetadata={onLoadedMetadata}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -140,6 +210,69 @@ export function VideoPlayer(props: VideoPlayerProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scrubber */}
|
||||||
|
<div class="pt-2">
|
||||||
|
<div class="w-full">
|
||||||
|
<div
|
||||||
|
class="relative flex w-full items-center h-10 touch-none cursor-grab select-none"
|
||||||
|
onMouseDown={onTrackMouseDown}
|
||||||
|
onTouchStart={onTrackTouchStart}
|
||||||
|
>
|
||||||
|
{/* Current time */}
|
||||||
|
<span
|
||||||
|
class="absolute left-0 top-0 transition-colors pointer-events-none rounded-md font-medium px-2 py-1 text-xs tabular-nums text-foreground"
|
||||||
|
style={{ opacity: 0.8, "background-color": "transparent" }}
|
||||||
|
>
|
||||||
|
{formatTime(currentTime())}
|
||||||
|
</span>
|
||||||
|
{/* Duration */}
|
||||||
|
<span
|
||||||
|
class="absolute right-0 top-0 transition-colors pointer-events-none rounded-md px-2 py-1 text-xs font-medium tabular-nums text-foreground"
|
||||||
|
style={{ "background-color": "transparent" }}
|
||||||
|
>
|
||||||
|
{formatTime(duration())}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Track */}
|
||||||
|
<div
|
||||||
|
ref={(el) => { trackRef = el; }}
|
||||||
|
class="relative w-full overflow-hidden rounded-xl bg-foreground/30"
|
||||||
|
style={{ height: "6px", opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{/* Fill */}
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-0 h-full rounded-xl dark:bg-white bg-black transition-[width] duration-75"
|
||||||
|
style={{ width: `${progress()}%` }}
|
||||||
|
/>
|
||||||
|
{/* Buffered / ghost */}
|
||||||
|
<div
|
||||||
|
class="absolute h-full pointer-events-none rounded-xl"
|
||||||
|
style={{ "background-color": "color-mix(in srgb, var(--foreground) 20%, transparent)", left: `${progress()}%`, width: "0px", opacity: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumb */}
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center pointer-events-none absolute top-1/2"
|
||||||
|
style={{
|
||||||
|
width: "18px",
|
||||||
|
height: "18px",
|
||||||
|
"margin-top": "-9px",
|
||||||
|
left: `${progress()}%`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
"z-index": 10,
|
||||||
|
transition: isDragging() ? "none" : "left 75ms linear",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block rounded-xl transition-colors bg-background dark:bg-white"
|
||||||
|
style={{ width: "14px", height: "14px" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function DashboardRoute() {
|
|||||||
const initialTab = () => {
|
const initialTab = () => {
|
||||||
const t = searchParams["tab"];
|
const t = searchParams["tab"];
|
||||||
const tab = Array.isArray(t) ? t[0] : t;
|
const tab = Array.isArray(t) ? t[0] : t;
|
||||||
const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings"];
|
const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings", "analytics"];
|
||||||
return validTabs.includes(tab as Section) ? (tab as Section) : "overview";
|
return validTabs.includes(tab as Section) ? (tab as Section) : "overview";
|
||||||
};
|
};
|
||||||
const [activeSection, setActiveSection] = createSignal<Section>(initialTab());
|
const [activeSection, setActiveSection] = createSignal<Section>(initialTab());
|
||||||
@@ -328,7 +328,7 @@ export function DashboardRoute() {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const t = searchParams["tab"];
|
const t = searchParams["tab"];
|
||||||
const tab = Array.isArray(t) ? t[0] : t;
|
const tab = Array.isArray(t) ? t[0] : t;
|
||||||
const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings"];
|
const validTabs: Section[] = ["overview", "bookings", "customers", "zones", "billing", "settings", "analytics"];
|
||||||
if (tab && validTabs.includes(tab as Section) && tab !== activeSection()) {
|
if (tab && validTabs.includes(tab as Section) && tab !== activeSection()) {
|
||||||
setIsPageTransitioning(true);
|
setIsPageTransitioning(true);
|
||||||
setTimeout(() => { setActiveSection(tab as Section); setIsPageTransitioning(false); }, 150);
|
setTimeout(() => { setActiveSection(tab as Section); setIsPageTransitioning(false); }, 150);
|
||||||
@@ -350,6 +350,7 @@ export function DashboardRoute() {
|
|||||||
{ id: "customers" as Section, label: i18n.t("dashboard.customers"), icon: UserCircleIcon },
|
{ id: "customers" as Section, label: i18n.t("dashboard.customers"), icon: UserCircleIcon },
|
||||||
{ id: "zones" as Section, label: i18n.t("dashboard.zones"), icon: MapPinIcon },
|
{ id: "zones" as Section, label: i18n.t("dashboard.zones"), icon: MapPinIcon },
|
||||||
{ id: "billing" as Section, label: i18n.t("dashboard.billing"), icon: CreditCardIcon },
|
{ id: "billing" as Section, label: i18n.t("dashboard.billing"), icon: CreditCardIcon },
|
||||||
|
{ id: "analytics" as Section, label: i18n.locale() === "cs" ? "Analytika" : "Analytics", icon: BarChart3Icon },
|
||||||
{ id: "settings" as Section, label: i18n.t("dashboard.settings"), icon: Settings2Icon },
|
{ id: "settings" as Section, label: i18n.t("dashboard.settings"), icon: Settings2Icon },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1394,6 +1395,156 @@ export function DashboardRoute() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ANALYTICS PAGE
|
||||||
|
// ==========================================
|
||||||
|
const AnalyticsPage = () => {
|
||||||
|
const cs = i18n.locale() === "cs";
|
||||||
|
const bookingData = normalizedAllBookings();
|
||||||
|
const totalBookings = bookingData.length;
|
||||||
|
const confirmed = bookingData.filter((b: { status: string }) => b.status === "confirmed").length;
|
||||||
|
const completed = bookingData.filter((b: { status: string }) => b.status === "completed").length;
|
||||||
|
const cancelled = bookingData.filter((b: { status: string }) => b.status === "cancelled").length;
|
||||||
|
const pending = bookingData.filter((b: { status: string }) => b.status === "pending").length;
|
||||||
|
const occupancyRate = totalBookings > 0 ? Math.round(((confirmed + completed) / totalBookings) * 100) : 0;
|
||||||
|
|
||||||
|
const kpiCards = [
|
||||||
|
{ label: cs ? "Celkem rezervací" : "Total bookings", value: totalBookings, change: "+12 %", trend: "up" as const, icon: CalendarDaysIcon },
|
||||||
|
{ label: cs ? "Potvrzeno" : "Confirmed", value: confirmed, change: "+8 %", trend: "up" as const, icon: CheckCircleIcon },
|
||||||
|
{ label: cs ? "Obsazenost" : "Occupancy", value: `${occupancyRate}%`, change: occupancyRate > 80 ? "+5 %" : "-3 %", trend: occupancyRate > 80 ? "up" as const : "down" as const, icon: TrendingUpIcon },
|
||||||
|
{ label: cs ? "Zrušeno" : "Cancelled", value: cancelled, change: "-2 %", trend: "down" as const, icon: XCircleIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const weeklyData = [
|
||||||
|
{ day: cs ? "Po" : "Mon", bookings: 12, revenue: 2400 },
|
||||||
|
{ day: cs ? "Út" : "Tue", bookings: 18, revenue: 3600 },
|
||||||
|
{ day: cs ? "St" : "Wed", bookings: 14, revenue: 2800 },
|
||||||
|
{ day: cs ? "Čt" : "Thu", bookings: 22, revenue: 4400 },
|
||||||
|
{ day: cs ? "Pá" : "Fri", bookings: 28, revenue: 5600 },
|
||||||
|
{ day: cs ? "So" : "Sat", bookings: 16, revenue: 3200 },
|
||||||
|
{ day: cs ? "Ne" : "Sun", bookings: 8, revenue: 1600 },
|
||||||
|
];
|
||||||
|
const maxRevenue = Math.max(...weeklyData.map((d) => d.revenue));
|
||||||
|
|
||||||
|
const statusBreakdown = [
|
||||||
|
{ label: cs ? "Potvrzeno" : "Confirmed", count: confirmed, color: "bg-accent" },
|
||||||
|
{ label: cs ? "Dokončeno" : "Completed", count: completed, color: "bg-success" },
|
||||||
|
{ label: cs ? "Čeká" : "Pending", count: pending, color: "bg-warning" },
|
||||||
|
{ label: cs ? "Zrušeno" : "Cancelled", count: cancelled, color: "bg-error" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`space-y-6 transition-opacity duration-200 ${isPageTransitioning() ? "opacity-0" : "opacity-100"}`}>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{cs ? "Analytika a reporty" : "Analytics & Reports"}</h1>
|
||||||
|
<p class="text-ink-muted mt-1">{cs ? "Podrobné statistiky, exporty a přehledy pro váš podnik." : "Detailed statistics, exports, and business insights."}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{kpiCards.map((kpi) => (
|
||||||
|
<div class="surface-card p-5">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-sm text-ink-muted">{kpi.label}</span>
|
||||||
|
<span class="w-8 h-8 rounded-lg bg-accent-subtle/50 flex items-center justify-center text-accent"><kpi.icon /></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-ink font-display">{kpi.value}</p>
|
||||||
|
<div class="flex items-center gap-1 mt-1">
|
||||||
|
<span class={`text-xs font-medium ${kpi.trend === "up" ? "text-success" : "text-error"}`}>{kpi.change}</span>
|
||||||
|
<span class="text-xs text-ink-subtle">{cs ? "vs minulý měsíc" : "vs last month"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Weekly Revenue Chart */}
|
||||||
|
<div class="lg:col-span-2 surface-card p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-ink">{cs ? "Týdenní přehled" : "Weekly Overview"}</h3>
|
||||||
|
<p class="text-sm text-ink-muted">{cs ? "Rezervace a tržby za posledních 7 dní" : "Bookings and revenue over the last 7 days"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full bg-accent" />
|
||||||
|
<span class="text-ink-muted">{cs ? "Rezervace" : "Bookings"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full bg-accent/30" />
|
||||||
|
<span class="text-ink-muted">{cs ? "Tržby (Kč)" : "Revenue ($)"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-3 h-48">
|
||||||
|
{weeklyData.map((d) => (
|
||||||
|
<div class="flex-1 flex flex-col items-center gap-2">
|
||||||
|
<div class="w-full flex items-end gap-1 h-36">
|
||||||
|
<div class="flex-1 rounded-t-md bg-accent/20" style={{ height: `${(d.bookings / 30) * 100}%` }} />
|
||||||
|
<div class="flex-1 rounded-t-md bg-accent" style={{ height: `${(d.revenue / maxRevenue) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-ink-subtle">{d.day}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Breakdown */}
|
||||||
|
<div class="surface-card p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-ink mb-1">{cs ? "Stav rezervací" : "Booking Status"}</h3>
|
||||||
|
<p class="text-sm text-ink-muted mb-6">{cs ? "Rozdělení podle stavu" : "Breakdown by status"}</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{statusBreakdown.map((s) => (
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
|
<span class="text-sm text-ink">{s.label}</span>
|
||||||
|
<span class="text-sm font-medium text-ink">{s.count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 rounded-full bg-canvas-subtle overflow-hidden">
|
||||||
|
<div class={`h-full rounded-full ${s.color} transition-all duration-500`} style={{ width: totalBookings > 0 ? `${(s.count / totalBookings) * 100}%` : "0%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 pt-6 border-t border-border/60">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-ink-muted">{cs ? "Konverzní poměr" : "Conversion rate"}</span>
|
||||||
|
<span class="text-lg font-bold text-ink font-display">{totalBookings > 0 ? Math.round(((confirmed + completed) / totalBookings) * 100) : 0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Services */}
|
||||||
|
<div class="surface-card p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-ink mb-4">{cs ? "Nejoblíbenější služby" : "Top Services"}</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{[
|
||||||
|
{ name: cs ? "Masáž" : "Massage", bookings: 45, revenue: 9000 },
|
||||||
|
{ name: cs ? "Kosmetika" : "Cosmetics", bookings: 32, revenue: 6400 },
|
||||||
|
{ name: cs ? "Fyzioterapie" : "Physiotherapy", bookings: 28, revenue: 8400 },
|
||||||
|
{ name: cs ? "Manikúra" : "Manicure", bookings: 24, revenue: 3600 },
|
||||||
|
].map((svc, i) => (
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-canvas-subtle flex items-center justify-center text-xs font-medium text-ink-subtle">{i + 1}</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-ink">{svc.name}</p>
|
||||||
|
<div class="h-1.5 rounded-full bg-canvas-subtle mt-1.5 overflow-hidden">
|
||||||
|
<div class="h-full rounded-full bg-accent" style={{ width: `${(svc.bookings / 45) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-sm font-medium text-ink">{svc.bookings}</p>
|
||||||
|
<p class="text-xs text-ink-subtle">{cs ? `${svc.revenue.toLocaleString()} Kč` : `$${svc.revenue.toLocaleString()}`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// ACTIVE PAGE SWITCHER
|
// ACTIVE PAGE SWITCHER
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1404,6 +1555,7 @@ export function DashboardRoute() {
|
|||||||
case "customers": return <CustomersPage />;
|
case "customers": return <CustomersPage />;
|
||||||
case "zones": return <ZonesPage />;
|
case "zones": return <ZonesPage />;
|
||||||
case "billing": return <BillingPage />;
|
case "billing": return <BillingPage />;
|
||||||
|
case "analytics": return <AnalyticsPage />;
|
||||||
case "settings": return <SettingsPage />;
|
case "settings": return <SettingsPage />;
|
||||||
default: return <OverviewPage />;
|
default: return <OverviewPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A, useNavigate } from "@solidjs/router";
|
||||||
import { createSignal, onMount, createMemo, Show, For } from "solid-js";
|
import { createSignal, onMount, createMemo, Show, For } from "solid-js";
|
||||||
import { useI18n } from "../providers/i18n-provider";
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
import { useTheme } from "../providers/theme-provider";
|
import { useTheme } from "../providers/theme-provider";
|
||||||
@@ -226,8 +226,17 @@ export function HomeRoute() {
|
|||||||
const [isVisible, setIsVisible] = createSignal(false);
|
const [isVisible, setIsVisible] = createSignal(false);
|
||||||
const [currentDate, setCurrentDate] = createSignal(new Date());
|
const [currentDate, setCurrentDate] = createSignal(new Date());
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
|
|
||||||
|
// Demo mode: redirect landing page to dashboard immediately
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const isDemo = hostname.includes("demo") || hostname === "localhost" || hostname === "127.0.0.1";
|
||||||
|
if (isDemo) {
|
||||||
|
navigate("/dashboard");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calendar helpers
|
// Calendar helpers
|
||||||
@@ -475,11 +484,22 @@ export function HomeRoute() {
|
|||||||
<p class="text-center text-sm text-ink-subtle mb-8 tracking-wide uppercase">
|
<p class="text-center text-sm text-ink-subtle mb-8 tracking-wide uppercase">
|
||||||
{i18n.t("home.trust")}
|
{i18n.t("home.trust")}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap items-center justify-center gap-8 lg:gap-16 opacity-60">
|
<div class="flex flex-wrap items-center justify-center gap-4 lg:gap-6">
|
||||||
{["Salon Ella", "Physio Care", "Massage Studio", "Yoga Flow", "Repair Pro"].map((name) => (
|
{[
|
||||||
<span class="text-lg lg:text-xl font-medium text-ink-muted tracking-tight">
|
{ name: "Salon Ella", icon: "SE" },
|
||||||
{name}
|
{ name: "Physio Care", icon: "PC" },
|
||||||
</span>
|
{ name: "Massage Studio", icon: "MS" },
|
||||||
|
{ name: "Yoga Flow", icon: "YF" },
|
||||||
|
{ name: "Repair Pro", icon: "RP" },
|
||||||
|
].map((biz) => (
|
||||||
|
<div class="flex items-center gap-2.5 px-4 py-2.5 rounded-xl bg-canvas border border-border/60 shadow-sm hover:shadow-md hover:border-border transition-all duration-300 group">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-accent-subtle/60 flex items-center justify-center text-[10px] font-bold text-accent tracking-wide">
|
||||||
|
{biz.icon}
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-ink-muted group-hover:text-ink transition-colors tracking-tight">
|
||||||
|
{biz.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -634,20 +654,40 @@ export function HomeRoute() {
|
|||||||
description: isCs()
|
description: isCs()
|
||||||
? "Podrobné statistiky, exporty a přehledy pro váš podnik."
|
? "Podrobné statistiky, exporty a přehledy pro váš podnik."
|
||||||
: "Detailed statistics, exports, and business insights.",
|
: "Detailed statistics, exports, and business insights.",
|
||||||
soon: true,
|
href: "/dashboard?tab=analytics",
|
||||||
children: (
|
children: (
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
<div class="w-full h-full flex flex-col gap-3">
|
||||||
<div class="text-center">
|
<div class="flex items-center justify-between">
|
||||||
<div class="w-12 h-12 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-2">
|
<span class="text-[10px] font-medium text-ink-muted">{isCs() ? "Příjmy" : "Revenue"}</span>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-ink-subtle">
|
<span class="text-[9px] text-accent font-semibold">+24 %</span>
|
||||||
<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>
|
</div>
|
||||||
</svg>
|
{/* Mini bar chart */}
|
||||||
|
<div class="flex items-end gap-1 h-16">
|
||||||
|
{[40, 55, 35, 70, 50, 80, 65].map((h) => (
|
||||||
|
<div class="flex-1 rounded-t-sm bg-accent/20 hover:bg-accent/40 transition-colors" style={{ height: `${h}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-[8px] text-ink-subtle">
|
||||||
|
<span>Po</span><span>Út</span><span>St</span><span>Čt</span><span>Pá</span><span>So</span><span>Ne</span>
|
||||||
|
</div>
|
||||||
|
{/* KPI row */}
|
||||||
|
<div class="mt-auto grid grid-cols-3 gap-2">
|
||||||
|
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
|
||||||
|
<p class="text-[9px] font-semibold text-ink">128</p>
|
||||||
|
<p class="text-[7px] text-ink-subtle">{isCs() ? "Rezervace" : "Bookings"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
|
||||||
|
<p class="text-[9px] font-semibold text-ink">94 %</p>
|
||||||
|
<p class="text-[7px] text-ink-subtle">{isCs() ? "Obsazenost" : "Occupancy"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-canvas-subtle/50 p-1.5 text-center">
|
||||||
|
<p class="text-[9px] font-semibold text-accent">15.2k</p>
|
||||||
|
<p class="text-[7px] text-ink-subtle">{isCs() ? "Kč" : "$"}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[10px] text-ink-subtle">{isCs() ? "Brzy dostupné" : "Coming soon"}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
containerClassName: "items-center justify-center",
|
fadeBottom: true,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -1073,7 +1113,7 @@ export function HomeRoute() {
|
|||||||
<div class="order-2 lg:order-1 flex justify-center">
|
<div class="order-2 lg:order-1 flex justify-center">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{/* Phone frame */}
|
{/* Phone frame */}
|
||||||
<div class="w-[285px] h-[560px] rounded-[40px] border-[6px] border-canvas-muted bg-canvas shadow-2xl overflow-hidden relative">
|
<div class="w-[285px] h-[620px] rounded-[40px] border-[6px] border-canvas-muted bg-canvas shadow-2xl overflow-hidden relative">
|
||||||
{/* Notch */}
|
{/* 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" />
|
<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 */}
|
{/* Screen content */}
|
||||||
@@ -1183,60 +1223,7 @@ export function HomeRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</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>
|
|
||||||
);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user