feat(ui): add analytics dashboard and enhance frontend components
CI / Frontend (push) Successful in 10m8s
CI / Go - apps/auth-service (push) Failing after 3s
CI / Go - apps/backend (push) Successful in 10m9s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

- 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:
Tomas Dvorak
2026-05-18 18:27:54 +02:00
parent da5ba13eab
commit 2039669e2c
5 changed files with 348 additions and 76 deletions
@@ -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
+134 -1
View File
@@ -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>
); );
} }
+154 -2
View File
@@ -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()}` : `$${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 />;
} }
+56 -69
View File
@@ -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></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>
);