Files
Bookra/apps/frontend/src/routes/dashboard-route.tsx
T
Tomas Dvorak cf3315e8fc
CI / Frontend (push) Successful in 11m7s
CI / Go - apps/auth-service (push) Failing after 8s
CI / Go - apps/backend (push) Failing after 2s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
cleanup
2026-05-05 09:48:15 +02:00

2964 lines
153 KiB
TypeScript

import {
Show,
createResource,
createSignal,
createMemo,
createEffect
} from "solid-js";
import { A, useSearchParams } from "@solidjs/router";
import { apiClient } from "../lib/api-client";
import { getPaddle, paddleConfigured } from "../lib/paddle";
import { useAuth } from "../providers/auth-provider";
import { useI18n } from "../providers/i18n-provider";
import { useTheme } from "../providers/theme-provider";
import type { JSX } from "solid-js";
import { BookraCharacter } from "../components/bookra-character";
import { WidgetBuilder } from "../components/widget-builder";
import { IntegrationModal } from "../components/integration-modal";
import { Select } from "../components/ui/select";
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
// ============================================
// TYPES & INTERFACES
// ============================================
type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
type BookingStatus = "confirmed" | "pending" | "cancelled";
interface KpiData {
label: string;
value: number | string;
change?: string;
trend?: "up" | "down" | "neutral";
icon: () => JSX.Element;
}
// ============================================
// SOPHISTICATED ICONS
// ============================================
const LayoutDashboardIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="9" x="3" y="3" rx="1" class="transition-all duration-300 group-hover:fill-orange-50"/>
<rect width="7" height="5" x="14" y="3" rx="1" class="transition-all duration-300 group-hover:fill-orange-50"/>
<rect width="7" height="9" x="14" y="12" rx="1" class="transition-all duration-300 group-hover:fill-orange-50"/>
<rect width="7" height="5" x="3" y="16" rx="1" class="transition-all duration-300 group-hover:fill-orange-50"/>
</svg>
);
const CalendarDaysIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v4"/><path d="M16 2v4"/>
<rect width="18" height="18" x="3" y="4" rx="2" class="transition-all duration-300 group-hover:fill-orange-50"/>
<path d="M3 10h18"/>
<path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/>
<path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/>
</svg>
);
const CreditCardIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="14" x="2" y="5" rx="2" class="transition-all duration-300 group-hover:fill-orange-50"/>
<line x1="2" x2="22" y1="10" y2="10"/>
</svg>
);
const Settings2Icon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 7h-9"/><path d="M14 17H5"/>
<circle cx="17" cy="17" r="3" class="transition-all duration-300 group-hover:fill-orange-50"/>
<circle cx="7" cy="7" r="3" class="transition-all duration-300 group-hover:fill-orange-50"/>
</svg>
);
const LogOutIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" x2="9" y1="12" y2="12"/>
</svg>
);
const MenuIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>
</svg>
);
const XIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
);
const TrendingUpIcon = () => (
<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">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
<polyline points="16 7 22 7 22 13"/>
</svg>
);
const TrendingDownIcon = () => (
<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">
<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/>
<polyline points="16 17 22 17 22 11"/>
</svg>
);
const ClockIcon = () => (
<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">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
);
const CheckCircleIcon = () => (
<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">
<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>
</svg>
);
const AlertCircleIcon = () => (
<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">
<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>
</svg>
);
const MoreHorizontalIcon = () => (
<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">
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
);
const ChevronLeftIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
);
const ChevronRightIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
);
const SparklesIcon = () => (
<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 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>
);
const BellIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
</svg>
);
const PlusIcon = () => (
<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">
<path d="M5 12h14"/><path d="M12 5v14"/>
</svg>
);
const UsersIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
);
const UserCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="10" r="3"/>
<path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/>
</svg>
);
const MapPinIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<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>
);
const BanIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="m4.9 4.9 14.2 14.2"/>
</svg>
);
const ClockOffIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 7v5l3 3"/>
<circle cx="12" cy="12" r="10"/>
<path d="m2 2 20 20"/>
</svg>
);
const SunIcon = () => (
<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">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>
</svg>
);
const MoonIcon = () => (
<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">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
);
const PaletteIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="13.5" cy="6.5" r=".5"/>
<circle cx="17.5" cy="10.5" r=".5"/>
<circle cx="8.5" cy="7.5" r=".5"/>
<circle cx="6.5" cy="12.5" r=".5"/>
<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.6 1.6 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>
);
const MailIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
);
const XCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="m15 9-6 6"/>
<path d="m9 9 6 6"/>
</svg>
);
const CalendarIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2"/>
<line x1="16" x2="16" y1="2" y2="6"/>
<line x1="8" x2="8" y1="2" y2="6"/>
<line x1="3" x2="21" y1="10" y2="10"/>
</svg>
);
// ============================================
// DEMO DATA GENERATOR
// ============================================
const createDemoData = (i18n: any) => ({
summary: {
tenantSlug: "demo-studio",
tenantName: "Serenity Wellness Studio",
publicBookingUrl: "http://localhost:3000/book/demo-studio",
kpis: [
{ label: i18n.locale() === 'cs' ? 'Celkem rezervací' : 'Total Bookings', value: 42, change: "+12%", trend: "up" },
{ label: i18n.locale() === 'cs' ? 'Zrušených' : 'Cancelled', value: 3, change: "-5%", trend: "down" },
{ label: i18n.locale() === 'cs' ? 'Dokončených' : 'Completed', value: 38, change: "+8%", trend: "up" },
{ label: i18n.locale() === 'cs' ? 'Noví klienti' : 'New Clients', value: 12, change: "+24%", trend: "up" },
],
upcomingBookings: [
{ id: "1", customerName: "Alice Johnson", service: "Yoga Flow Class", status: "confirmed" as BookingStatus, startsAt: new Date(Date.now() + 86400000).toISOString(), reference: "BK-001", avatar: "AJ", duration: "60 min" },
{ id: "2", customerName: "Bob Smith", service: "Deep Tissue Massage", status: "pending" as BookingStatus, startsAt: new Date(Date.now() + 172800000).toISOString(), reference: "BK-002", avatar: "BS", duration: "90 min" },
{ id: "3", customerName: "Carol White", service: "Personal Training", status: "confirmed" as BookingStatus, startsAt: new Date(Date.now() + 259200000).toISOString(), reference: "BK-003", avatar: "CW", duration: "45 min" },
{ id: "4", customerName: "David Brown", service: "Nutrition Consultation", status: "cancelled" as BookingStatus, startsAt: new Date(Date.now() + 345600000).toISOString(), reference: "BK-004", avatar: "DB", duration: "30 min" },
{ id: "5", customerName: "Emma Wilson", service: "Meditation Session", status: "confirmed" as BookingStatus, startsAt: new Date(Date.now() + 432000000).toISOString(), reference: "BK-005", avatar: "EW", duration: "45 min" },
],
recentActivity: [
{ action: "New booking received", detail: "Alice Johnson • Yoga Flow Class", time: "2 min ago", type: "booking" },
{ action: "Booking rescheduled", detail: "David Brown moved to Friday 2pm", time: "1 hour ago", type: "reschedule" },
{ action: "New client registered", detail: "Emma Wilson joined", time: "3 hours ago", type: "client" },
{ action: "Reminder sent", detail: "Tomorrow's session confirmed", time: "6 hours ago", type: "reminder" },
{ action: "Booking cancelled", detail: "Carol White • Personal Training", time: "8 hours ago", type: "cancel" },
],
allBookings: [
{ id: "1", customerName: "Alice Johnson", customerEmail: "alice@example.com", service: "Yoga Flow Class", status: "confirmed", startsAt: new Date(Date.now() + 86400000).toISOString(), endsAt: new Date(Date.now() + 86400000 + 3600000).toISOString(), reference: "BK-001", avatar: "AJ", duration: "60 min", location: "Main Studio", createdAt: new Date(Date.now() - 86400000).toISOString() },
{ id: "2", customerName: "Bob Smith", customerEmail: "bob@example.com", service: "Deep Tissue Massage", status: "pending", startsAt: new Date(Date.now() + 172800000).toISOString(), endsAt: new Date(Date.now() + 172800000 + 5400000).toISOString(), reference: "BK-002", avatar: "BS", duration: "90 min", location: "Treatment Room A", createdAt: new Date(Date.now() - 172800000).toISOString() },
{ id: "3", customerName: "Carol White", customerEmail: "carol@example.com", service: "Personal Training", status: "confirmed", startsAt: new Date(Date.now() + 259200000).toISOString(), endsAt: new Date(Date.now() + 259200000 + 2700000).toISOString(), reference: "BK-003", avatar: "CW", duration: "45 min", location: "Main Studio", createdAt: new Date(Date.now() - 259200000).toISOString() },
{ id: "4", customerName: "David Brown", customerEmail: "david@example.com", service: "Nutrition Consultation", status: "cancelled", startsAt: new Date(Date.now() + 345600000).toISOString(), endsAt: new Date(Date.now() + 345600000 + 1800000).toISOString(), reference: "BK-004", avatar: "DB", duration: "30 min", location: "Treatment Room B", createdAt: new Date(Date.now() - 345600000).toISOString() },
{ id: "5", customerName: "Emma Wilson", customerEmail: "emma@example.com", service: "Meditation Session", status: "confirmed", startsAt: new Date(Date.now() + 432000000).toISOString(), endsAt: new Date(Date.now() + 432000000 + 2700000).toISOString(), reference: "BK-005", avatar: "EW", duration: "45 min", location: "Group Hall", createdAt: new Date(Date.now() - 432000000).toISOString() },
{ id: "6", customerName: "Frank Miller", customerEmail: "frank@example.com", service: "Yoga Flow Class", status: "completed", startsAt: new Date(Date.now() - 86400000).toISOString(), endsAt: new Date(Date.now() - 86400000 + 3600000).toISOString(), reference: "BK-006", avatar: "FM", duration: "60 min", location: "Main Studio", createdAt: new Date(Date.now() - 172800000).toISOString() },
],
},
bootstrap: {
tenantId: "demo-tenant-id",
tenantName: "Serenity Wellness Studio",
preset: "studio",
locale: i18n.locale(),
timezone: "Europe/Prague",
currentUser: { role: "admin", name: "Demo User", email: "demo@bookra.io" },
brand: { primaryColor: "#a65c3e", name: "Serenity Wellness Studio" },
},
billing: {
provider: "paddle",
planCode: "pro",
status: "active",
nextBilling: "2024-02-15",
amount: "$49",
checkoutUrlAvailable: true,
syncAvailable: true,
portalAvailable: true,
entitlements: { maxLocations: 3, maxStaff: 10, emailReminders: true, customBranding: true, analytics: true },
},
});
const getInitials = (name?: string) =>
(name ?? "User")
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("") || "U";
const getBookingDuration = (startsAt?: string, endsAt?: string) => {
if (!startsAt || !endsAt) return "60 min";
const start = new Date(startsAt).getTime();
const end = new Date(endsAt).getTime();
if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return "60 min";
return `${Math.round((end - start) / 60000)} min`;
};
// ============================================
// ANIMATED CALENDAR COMPONENT
// ============================================
function CalendarView(props: { bookings: any[]; locale?: string }) {
const [currentDate, setCurrentDate] = createSignal(new Date());
const [selectedDay, setSelectedDay] = createSignal<number | null>(null);
const [isAnimating, setIsAnimating] = createSignal(false);
const isCs = () => props.locale === 'cs';
const weekDays = isCs()
? ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"]
: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const calendarDays = createMemo(() => {
const date = currentDate();
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: Array<{ day: number | null; bookings: any[]; isToday: boolean }> = [];
for (let i = 0; i < firstDay; i++) days.push({ day: null, bookings: [], isToday: false });
const today = new Date();
for (let day = 1; day <= daysInMonth; day++) {
const dayBookings = props.bookings.filter(b => {
const bookingDate = new Date(b.startsAt);
return bookingDate.getDate() === day && bookingDate.getMonth() === month;
});
const isToday = today.getDate() === day && today.getMonth() === month && today.getFullYear() === year;
days.push({ day, bookings: dayBookings, isToday });
}
return days;
});
const changeMonth = (direction: number) => {
setIsAnimating(true);
setTimeout(() => {
setCurrentDate(new Date(currentDate().getFullYear(), currentDate().getMonth() + direction, 1));
setIsAnimating(false);
}, 150);
};
const prevMonth = () => changeMonth(-1);
const nextMonth = () => changeMonth(1);
return (
<div class="surface-card p-6 shadow-sm hover:shadow-lg transition-all duration-500">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">
{new Intl.DateTimeFormat(isCs() ? 'cs-CZ' : 'en-US', { month: 'long', year: 'numeric' }).format(currentDate())}
</h3>
<p class="text-sm text-ink-muted mt-0.5">{props.bookings.length} {isCs() ? 'rezervací tento měsíc' : 'bookings this month'}</p>
</div>
<div class="flex gap-2">
<button onClick={prevMonth} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all duration-200 hover:scale-105 active:scale-95">
<ChevronLeftIcon />
</button>
<button onClick={nextMonth} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all duration-200 hover:scale-105 active:scale-95">
<ChevronRightIcon />
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-1 mb-3">
{weekDays.map(day => (
<div class="text-center text-xs font-semibold text-ink-subtle uppercase tracking-wider py-2">{day}</div>
))}
</div>
<div class={`grid grid-cols-7 gap-1 transition-opacity duration-200 ${isAnimating() ? 'opacity-0' : 'opacity-100'}`}>
{calendarDays().map(({ day, bookings, isToday }, index) => (
<div
class={`
aspect-square p-1.5 rounded-xl border-2 transition-all duration-300 cursor-pointer relative overflow-hidden
${day ? 'bg-canvas border-border hover:border-accent hover:shadow-md hover:-translate-y-0.5' : 'bg-transparent border-transparent'}
${isToday ? 'bg-accent-subtle border-accent shadow-sm' : ''}
${selectedDay() === day ? 'ring-2 ring-accent ring-offset-2 ring-offset-canvas' : ''}
${bookings.length > 0 && !isToday ? 'bg-gradient-to-br from-accent-subtle/50 to-canvas' : ''}
`}
style={{ "animation-delay": `${index * 20}ms` }}
classList={{ "animate-fade-in": true }}
onClick={() => day && setSelectedDay(selectedDay() === day ? null : day)}
>
{day && (
<>
<span class={`text-sm font-medium ${isToday ? 'text-accent' : 'text-ink'}`}>{day}</span>
{bookings.length > 0 && (
<div class="mt-1 flex flex-wrap gap-0.5">
{bookings.slice(0, 4).map((booking: any, i: number) => (
<div
class={`w-1.5 h-1.5 rounded-full ${booking.status === 'confirmed' ? 'bg-green-500' : booking.status === 'pending' ? 'bg-amber-500' : 'bg-red-400'}`}
title={`${booking.customerName} - ${booking.service}`}
/>
))}
{bookings.length > 4 && (
<span class="text-[7px] font-bold text-accent leading-none">+{bookings.length - 4}</span>
)}
</div>
)}
</>
)}
</div>
))}
</div>
{/* Selected Day Preview */}
<Show when={selectedDay()}>
<div class="mt-4 p-4 bg-canvas-subtle rounded-xl border border-border animate-fade-in">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold text-ink">{new Intl.DateTimeFormat(isCs() ? 'cs-CZ' : 'en-US', { month: 'long' }).format(currentDate())} {selectedDay()}</span>
<button onClick={() => setSelectedDay(null)} class="text-xs text-ink-muted hover:text-ink">{isCs() ? 'Zavřít' : 'Close'}</button>
</div>
<div class="space-y-2">
{calendarDays().find(d => d.day === selectedDay())?.bookings.map((booking: any) => (
<div class="flex items-center gap-3 p-2 bg-canvas rounded-lg shadow-sm">
<div class={`w-2 h-2 rounded-full ${booking.status === 'confirmed' ? 'bg-[hsl(var(--success))]' : booking.status === 'pending' ? 'bg-[hsl(var(--warning))]' : 'bg-[hsl(var(--error))]'}`} />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{booking.customerName}</p>
<p class="text-xs text-ink-muted">{booking.service} {booking.duration}</p>
</div>
<span class="text-xs text-ink-subtle">{new Date(booking.startsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
))}
</div>
</div>
</Show>
</div>
);
}
// ============================================
// KPI CARD COMPONENT
// ============================================
function KpiCard(props: { kpi: any; index: number }) {
const [isHovered, setIsHovered] = createSignal(false);
const trend = () => props.kpi.trend ?? "neutral";
const trendClass = () => {
switch (trend()) {
case "up":
return "bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]";
case "down":
return "bg-[hsl(var(--error-soft))] text-[hsl(var(--error))]";
default:
return "bg-canvas-subtle text-ink-muted";
}
};
const sparklineClass = () => {
switch (trend()) {
case "up":
return "bg-[hsl(var(--success-muted))] group-hover:bg-[hsl(var(--success-subtle))]";
case "down":
return "bg-[hsl(var(--error-muted))] group-hover:bg-[hsl(var(--error-subtle))]";
default:
return "bg-canvas-muted group-hover:bg-border";
}
};
return (
<div
class="group relative surface-card p-5 hover:shadow-xl transition-all duration-500 hover:-translate-y-1 overflow-hidden"
style={{ "animation-delay": `${props.index * 100}ms` }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Background gradient on hover */}
<div class={`absolute inset-0 bg-gradient-to-br from-accent-subtle/0 via-accent-subtle/0 to-accent-subtle/30 transition-opacity duration-500 ${isHovered() ? 'opacity-100' : 'opacity-0'}`} />
<div class="relative flex items-start justify-between">
<div>
<p class="text-sm font-medium text-ink-muted group-hover:text-ink transition-colors">{props.kpi.label}</p>
<p class="text-3xl font-bold text-ink mt-2 tracking-tight">{props.kpi.value}</p>
</div>
<div class={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold transition-all duration-300 ${trendClass()} ${isHovered() ? 'scale-105' : ''}`}>
<Show when={trend() === "up"} fallback={<Show when={trend() === "down"} fallback={<MoreHorizontalIcon />}><TrendingDownIcon /></Show>}>
<TrendingUpIcon />
</Show>
{props.kpi.change ?? "Live"}
</div>
</div>
{/* Mini sparkline visualization */}
<div class="mt-4 flex items-end gap-0.5 h-8">
{[40, 65, 45, 80, 55, 70, 90, 60, 75, 85].map((height, i) => (
<div
class={`flex-1 rounded-t-sm transition-all duration-300 ${sparklineClass()}`}
style={{ height: `${height}%`, "transition-delay": `${i * 30}ms` }}
/>
))}
</div>
</div>
);
}
// ============================================
// ACTIVITY TIMELINE COMPONENT
// ============================================
function ActivityTimeline(props: { activities: any[] }) {
const getIcon = (type: string) => {
switch (type) {
case 'booking': return <div class="w-8 h-8 rounded-full bg-[hsl(var(--info-subtle))] text-[hsl(var(--info))] flex items-center justify-center"><CheckCircleIcon /></div>;
case 'cancel': return <div class="w-8 h-8 rounded-full bg-[hsl(var(--error-subtle))] text-[hsl(var(--error))] flex items-center justify-center"><AlertCircleIcon /></div>;
case 'client': return <div class="w-8 h-8 rounded-full bg-[hsl(270_40%_20%)] text-[hsl(270_50%_60%)] flex items-center justify-center"><UsersIcon /></div>;
case 'reschedule': return <div class="w-8 h-8 rounded-full bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))] flex items-center justify-center"><ClockIcon /></div>;
case 'reminder': return <div class="w-8 h-8 rounded-full bg-[hsl(var(--success-subtle))] text-[hsl(var(--success))] flex items-center justify-center"><BellIcon /></div>;
default: return <div class="w-8 h-8 rounded-full bg-canvas-subtle text-ink-muted flex items-center justify-center"><MoreHorizontalIcon /></div>;
}
};
return (
<div class="surface-card p-6 shadow-sm">
<h3 class="text-lg font-semibold text-ink mb-5">Recent Activity</h3>
<div class="space-y-0">
{props.activities.map((activity: any, index: number) => (
<div class="group flex gap-4 relative" style={{ "animation-delay": `${index * 75}ms` }}>
{/* Timeline line */}
{index < props.activities.length - 1 && (
<div class="absolute left-4 top-8 bottom-0 w-px bg-gradient-to-b from-border to-transparent" />
)}
<div class="relative z-10 flex-shrink-0 transition-transform duration-300 group-hover:scale-110">
{getIcon(activity.type)}
</div>
<div class="flex-1 pb-5">
<div class="flex items-start justify-between">
<div>
<p class="text-sm font-semibold text-ink group-hover:text-accent transition-colors">{activity.action}</p>
<p class="text-sm text-ink-muted mt-0.5">{activity.detail}</p>
</div>
<span class="text-xs text-ink-subtle whitespace-nowrap">{activity.time}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
// ============================================
// MAIN DASHBOARD ROUTE
// ============================================
export function DashboardRoute() {
const i18n = useI18n();
const auth = useAuth();
const theme = useTheme();
const [searchParams] = useSearchParams();
const [activeSection, setActiveSection] = createSignal<Section>("overview");
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
const [isPageTransitioning, setIsPageTransitioning] = createSignal(false);
const [billingNotice, setBillingNotice] = createSignal<string | null>(null);
const [billingError, setBillingError] = createSignal<string | null>(null);
const [billingAction, setBillingAction] = createSignal<"checkout" | "refresh" | "portal" | null>(null);
const [handledBillingState, setHandledBillingState] = createSignal<string | null>(null);
const [showIntegrationModal, setShowIntegrationModal] = createSignal(false);
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 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 ?? "Live",
})));
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}${booking.service}`,
time: new Date(booking.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
type: "booking",
}));
});
const billingPriceLabel = createMemo(() => {
const current = resolvedBilling();
if (!current) return "--";
if (current.amount) return current.amount;
const displayPrice = current.displayPrices?.find((price: any) => price.currency === current.currency) ?? current.displayPrices?.[0];
return displayPrice?.formatted ?? "--";
});
const nextBillingLabel = createMemo(() => {
const current = resolvedBilling();
if (!current) return "--";
if (current.nextBilling) return current.nextBilling;
if (!current.currentPeriodEnd) return i18n.locale() === "cs" ? "Není naplánováno" : "Not scheduled";
return new Date(current.currentPeriodEnd).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
});
const paymentMethodLabel = createMemo(() => {
const current = resolvedBilling();
if (!current) return "--";
if (current.paymentMethodBrand && current.paymentMethodLast4) {
return `${current.paymentMethodBrand} •••• ${current.paymentMethodLast4}`;
}
return i18n.locale() === "cs" ? "Není k dispozici" : "Not available";
});
const hasTenant = () => Boolean(bootstrap.latest?.tenantId) || isDemoMode();
const isDashboardReady = () => Boolean(token() && hasTenant() && (summary.latest || isDemoMode()) && (billing.latest || isDemoMode()));
const refreshBilling = async (silent = false) => {
const bearer = token();
if (!bearer) return;
if (bearer.startsWith("demo.")) {
if (!silent) setBillingNotice(i18n.locale() === "cs" ? "Demo předplatné je statické." : "Demo subscription is static.");
return;
}
setBillingAction("refresh");
if (!silent) {
setBillingError(null);
setBillingNotice(null);
}
const response = await apiClient.POST("/v1/billing/refresh", {
headers: { Authorization: `Bearer ${bearer}` },
});
setBillingAction(null);
if (response.error) {
setBillingError(i18n.locale() === "cs" ? "Obnovení předplatného selhalo." : "Billing refresh failed.");
return;
}
await refetchBilling();
setBillingNotice(i18n.locale() === "cs" ? "Předplatné bylo obnoveno." : "Billing state refreshed.");
};
const openCheckout = async () => {
const bearer = token();
if (!bearer) return;
if (bearer.startsWith("demo.")) {
setBillingNotice(i18n.locale() === "cs" ? "Checkout není v demo režimu dostupný." : "Checkout is unavailable in demo mode.");
return;
}
if (!paddleConfigured()) {
setBillingError(i18n.locale() === "cs" ? "Paddle client token není nastaven." : "Paddle client token is not configured.");
return;
}
setBillingAction("checkout");
setBillingError(null);
setBillingNotice(null);
const current = resolvedBilling();
const response = await apiClient.POST("/v1/billing/checkout", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
planCode: current.planCode,
currency: current.currency,
},
});
setBillingAction(null);
if (response.error || !response.data?.priceId) {
setBillingError(i18n.locale() === "cs" ? "Paddle checkout není připraven." : "Paddle checkout is not ready.");
return;
}
const paddle = await getPaddle();
if (!paddle) {
setBillingError(i18n.locale() === "cs" ? "Paddle se nepodařilo inicializovat." : "Paddle failed to initialize.");
return;
}
paddle.Checkout.open({
items: [{ priceId: response.data.priceId, quantity: 1 }],
customer: response.data.customerId
? { id: response.data.customerId }
: response.data.customerEmail
? { email: response.data.customerEmail }
: undefined,
customData: response.data.customData,
settings: {
displayMode: "overlay",
successUrl: response.data.successRedirectUrl,
},
});
};
const openBillingPortal = async () => {
const bearer = token();
if (!bearer) return;
if (bearer.startsWith("demo.")) {
setBillingNotice(i18n.locale() === "cs" ? "Portál není v demo režimu dostupný." : "Portal is unavailable in demo mode.");
return;
}
setBillingAction("portal");
setBillingError(null);
setBillingNotice(null);
const response = await apiClient.POST("/v1/billing/portal", {
headers: { Authorization: `Bearer ${bearer}` },
});
setBillingAction(null);
if (response.error || !response.data?.url) {
setBillingError(i18n.locale() === "cs" ? "Paddle portál není připraven." : "Paddle portal is not ready.");
return;
}
const portalUrl = response.data.url;
if (!portalUrl.startsWith("https://")) {
setBillingError(i18n.locale() === "cs" ? "Neplatný portál URL." : "Invalid portal URL.");
return;
}
window.location.href = portalUrl;
};
createEffect(() => {
const billingState = Array.isArray(searchParams.billing) ? searchParams.billing[0] : searchParams.billing;
const bearer = token();
if (!billingState || billingState === handledBillingState() || !bearer) return;
setHandledBillingState(billingState);
setActiveSection("billing");
if (billingState === "success") {
void refreshBilling();
return;
}
if (billingState === "cancelled") {
setBillingError(null);
setBillingNotice(i18n.locale() === "cs" ? "Checkout byl zrušen." : "Checkout was cancelled.");
}
});
// Smooth page transition
const changeSection = (section: Section) => {
if (section === activeSection()) return;
setIsPageTransitioning(true);
setTimeout(() => {
setActiveSection(section);
setIsPageTransitioning(false);
}, 150);
};
const navItems = [
{ id: "overview" as Section, label: i18n.locale() === 'cs' ? 'Prehled' : 'Overview', icon: LayoutDashboardIcon },
{ id: "bookings" as Section, label: i18n.locale() === 'cs' ? 'Rezervace' : 'Bookings', icon: CalendarDaysIcon },
{ id: "customers" as Section, label: i18n.locale() === 'cs' ? 'Zakaznici' : 'Customers', icon: UserCircleIcon },
{ id: "zones" as Section, label: i18n.locale() === 'cs' ? 'Zony a dostupnost' : 'Zones & Availability', icon: MapPinIcon },
{ id: "billing" as Section, label: i18n.locale() === 'cs' ? 'Platby' : 'Billing', icon: CreditCardIcon },
{ id: "settings" as Section, label: i18n.locale() === 'cs' ? 'Nastaveni' : 'Settings', icon: Settings2Icon },
];
// ==========================================
// PAGE COMPONENTS
// ==========================================
const OverviewPage = () => (
<div class="space-y-6 animate-fade-in">
{/* 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">
{i18n.locale() === 'cs' ? 'Dobrý den,' : 'Welcome back,'} <span class="text-accent">{auth.session()?.user?.name}</span>
</h1>
<p class="text-ink-muted mt-1">{i18n.locale() === 'cs' ? `Přehled pro ${resolvedBootstrap().tenantName}` : `Overview for ${resolvedBootstrap().tenantName}`}</p>
</div>
<div class="flex items-center gap-3">
<button
onClick={() => 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>
{i18n.locale() === 'cs' ? 'Sdílet/Spravit' : 'Share/Manage'}
</button>
<span class="text-sm text-ink-subtle">{new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
<button class="p-2 text-ink-subtle hover:text-ink hover:bg-canvas-subtle rounded-xl transition-all">
<BellIcon />
</button>
</div>
</div>
{/* KPI Cards */}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{normalizedKpis().map((kpi: any, index: number) => (
<KpiCard kpi={kpi} index={index} />
))}
</div>
{/* Calendar + Activity */}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<CalendarView bookings={normalizedUpcomingBookings()} locale={i18n.locale()} />
</div>
<div class="space-y-6">
<ActivityTimeline activities={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">{i18n.locale() === 'cs' ? 'Nadcházející rezervace' : 'Upcoming Bookings'}</h3>
<p class="text-sm text-ink-muted mt-0.5">{normalizedUpcomingBookings().length} total</p>
</div>
<button
onClick={() => changeSection("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"
>
{i18n.locale() === 'cs' ? 'Zobrazit vše' : 'View all'}
<ChevronRightIcon />
</button>
</div>
<div class="divide-y divide-border">
{normalizedUpcomingBookings().slice(0, 3).map((booking: any, index: number) => (
<div
class="group py-4 flex items-center justify-between hover:bg-canvas-subtle/50 rounded-xl px-2 -mx-2 transition-all duration-300 cursor-pointer"
style={{ "animation-delay": `${index * 100}ms` }}
>
<div class="flex items-center gap-4">
<div class="relative">
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center text-sm font-bold text-accent shadow-sm group-hover:shadow-md transition-shadow">
{booking.avatar}
</div>
<div class={`absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full border-2 border-canvas ${booking.status === 'confirmed' ? 'bg-[hsl(var(--success))]' : booking.status === 'pending' ? 'bg-[hsl(var(--warning))]' : 'bg-[hsl(var(--error))]'}`} />
</div>
<div>
<p class="font-semibold text-ink group-hover:text-accent transition-colors">{booking.customerName}</p>
<p class="text-sm text-ink-muted">{booking.service}</p>
</div>
</div>
<div class="text-right">
<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.5 py-0.5 rounded-full text-xs font-semibold ${booking.status === 'confirmed' ? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]' : booking.status === 'pending' ? 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]' : 'bg-[hsl(var(--error-soft))] text-[hsl(var(--error))]'}`}>
{booking.status}
</span>
</div>
</div>
))}
</div>
</div>
</div>
);
const BookingsPage = () => {
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedBooking, setSelectedBooking] = createSignal<any>(null);
const [showBookingDetail, setShowBookingDetail] = createSignal(false);
const [showNewBooking, setShowNewBooking] = createSignal(false);
const [editingBooking, setEditingBooking] = createSignal(false);
// Booking form state
const [newBookingCustomer, setNewBookingCustomer] = createSignal("");
const [newBookingEmail, setNewBookingEmail] = createSignal("");
const [newBookingService, setNewBookingService] = createSignal("");
const [newBookingDate, setNewBookingDate] = createSignal("");
const [newBookingTime, setNewBookingTime] = createSignal("");
const [newBookingLocation, setNewBookingLocation] = createSignal("");
const [newBookingNotes, setNewBookingNotes] = createSignal("");
// Fetch all bookings
const [allBookings, { refetch: refetchBookings }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return demoData().summary.allBookings;
const response = await (apiClient as any).GET("/v1/catalog/bookings", {
headers: { Authorization: `Bearer ${bearer}` }
});
return response.data ?? [];
});
const resolvedAllBookings = () => (allBookings.latest as any[]) ?? demoData().summary.allBookings ?? [];
// Filter bookings
const filteredBookings = createMemo(() => {
let bookings = resolvedAllBookings();
// Status filter
if (filterStatus() !== "all") {
bookings = bookings.filter((b: any) => b.status === filterStatus());
}
// Date range filter
const now = new Date();
if (filterDateRange() === "today") {
bookings = bookings.filter((b: any) => {
const date = new Date(b.startsAt);
return date.toDateString() === now.toDateString();
});
} else if (filterDateRange() === "week") {
const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
bookings = bookings.filter((b: any) => {
const date = new Date(b.startsAt);
return date >= now && date <= weekEnd;
});
} else if (filterDateRange() === "month") {
const monthEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
bookings = bookings.filter((b: any) => {
const date = new Date(b.startsAt);
return date >= now && date <= monthEnd;
});
}
// Search filter
if (searchQuery().trim()) {
const query = searchQuery().toLowerCase();
bookings = bookings.filter((b: any) =>
b.customerName?.toLowerCase().includes(query) ||
b.customerEmail?.toLowerCase().includes(query) ||
b.service?.toLowerCase().includes(query) ||
b.reference?.toLowerCase().includes(query)
);
}
// Sort by date, newest first
return bookings.sort((a: any, b: any) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime());
});
// Stats
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 = token();
if (!bearer || bearer.startsWith("demo.")) {
setShowNewBooking(false);
return;
}
const dateTime = new Date(`${newBookingDate()}T${newBookingTime()}`);
await (apiClient as any).POST("/v1/catalog/bookings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
customerName: newBookingCustomer(),
customerEmail: newBookingEmail(),
service: newBookingService(),
locationId: newBookingLocation(),
startsAt: dateTime.toISOString(),
endsAt: new Date(dateTime.getTime() + 60 * 60 * 1000).toISOString(),
notes: newBookingNotes(),
status: "confirmed"
}
});
// Reset form
setNewBookingCustomer("");
setNewBookingEmail("");
setNewBookingService("");
setNewBookingDate("");
setNewBookingTime("");
setNewBookingLocation("");
setNewBookingNotes("");
setShowNewBooking(false);
void refetchBookings();
};
const handleUpdateBooking = async () => {
const bearer = token();
const booking = selectedBooking();
if (!bearer || !booking || bearer.startsWith("demo.")) {
setEditingBooking(false);
setShowBookingDetail(false);
return;
}
await (apiClient as any).PUT(`/v1/catalog/bookings/${booking.id}`, {
headers: { Authorization: `Bearer ${bearer}` },
body: {
customerName: newBookingCustomer(),
customerEmail: newBookingEmail(),
service: newBookingService(),
locationId: newBookingLocation(),
notes: newBookingNotes()
}
});
setEditingBooking(false);
setShowBookingDetail(false);
setSelectedBooking(null);
void refetchBookings();
};
const handleCancelBooking = async (bookingId: string) => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) return;
if (!confirm(i18n.locale() === 'cs' ? 'Opravdu chcete zrušit tuto rezervaci?' : 'Are you sure you want to cancel this booking?')) return;
await (apiClient as any).POST(`/v1/catalog/bookings/${bookingId}/cancel`, {
headers: { Authorization: `Bearer ${bearer}` }
});
void refetchBookings();
};
const handleRescheduleBooking = async (bookingId: string, newDate: string, newTime: string) => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) return;
const newDateTime = new Date(`${newDate}T${newTime}`);
await (apiClient as any).POST(`/v1/catalog/bookings/${bookingId}/reschedule`, {
headers: { Authorization: `Bearer ${bearer}` },
body: {
startsAt: newDateTime.toISOString(),
endsAt: new Date(newDateTime.getTime() + 60 * 60 * 1000).toISOString()
}
});
void refetchBookings();
};
const openBookingDetail = (booking: any) => {
setSelectedBooking(booking);
setNewBookingCustomer(booking.customerName);
setNewBookingEmail(booking.customerEmail);
setNewBookingService(booking.service);
setNewBookingLocation(booking.location || "");
setNewBookingNotes(booking.notes || "");
setShowBookingDetail(true);
setEditingBooking(false);
};
const statusColors: Record<string, string> = {
confirmed: "bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]",
pending: "bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]",
cancelled: "bg-[hsl(var(--error-soft))] text-[hsl(var(--error))]",
completed: "bg-canvas-muted text-ink-muted"
};
const statusLabels: Record<string, string> = {
confirmed: i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed',
pending: i18n.locale() === 'cs' ? 'Čeká' : 'Pending',
cancelled: i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled',
completed: i18n.locale() === 'cs' ? 'Dokončeno' : 'Completed'
};
return (
<div class={`space-y-6 transition-opacity duration-200 ${isPageTransitioning() ? 'opacity-0' : 'opacity-100'}`}>
{/* 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">{i18n.locale() === 'cs' ? 'Správa rezervací' : 'Booking Management'}</h1>
<p class="text-ink-muted mt-1">{bookingStats().total} {i18n.locale() === 'cs' ? 'celkem rezervací' : 'total bookings'}</p>
</div>
<button
onClick={() => setShowNewBooking(true)}
class="btn-primary"
>
<PlusIcon />
{i18n.locale() === 'cs' ? 'Nová rezervace' : 'New Booking'}
</button>
</div>
{/* Stats Cards */}
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4">
{[
{ label: i18n.locale() === 'cs' ? 'Celkem' : 'Total', value: bookingStats().total, color: 'bg-canvas-subtle' },
{ label: i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed', value: bookingStats().confirmed, color: 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]' },
{ label: i18n.locale() === 'cs' ? 'Čeká' : 'Pending', value: bookingStats().pending, color: 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]' },
{ label: i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled', value: bookingStats().cancelled, color: 'bg-[hsl(var(--error-soft))] text-[hsl(var(--error))]' },
{ label: i18n.locale() === 'cs' ? 'Dokončeno' : 'Completed', value: bookingStats().completed, color: 'bg-canvas-muted text-ink-muted' },
].map((stat, index) => (
<div
class={`${stat.color} p-4 rounded-xl border border-border`}
style={{ "animation-delay": `${index * 50}ms` }}
>
<p class="text-2xl font-bold">{stat.value}</p>
<p class="text-sm opacity-80">{stat.label}</p>
</div>
))}
</div>
{/* Filters */}
<div class="surface-card p-4">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<Input
type="text"
placeholder={i18n.locale() === 'cs' ? 'Hledat podle jména, emailu nebo reference...' : 'Search by name, email, or reference...'}
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
/>
</div>
<div class="flex gap-2">
<Select
value={filterStatus()}
onChange={(v) => setFilterStatus(v as any)}
class="min-w-[140px]"
options={[
{ value: "all", label: i18n.locale() === 'cs' ? 'Všechny stavy' : 'All Statuses' },
{ value: "confirmed", label: i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed' },
{ value: "pending", label: i18n.locale() === 'cs' ? 'Čeká' : 'Pending' },
{ value: "cancelled", label: i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled' },
{ value: "completed", label: i18n.locale() === 'cs' ? 'Dokončeno' : 'Completed' },
]}
/>
<Select
value={filterDateRange()}
onChange={(v) => setFilterDateRange(v as any)}
class="min-w-[140px]"
options={[
{ value: "all", label: i18n.locale() === 'cs' ? 'Všechny termíny' : 'All Dates' },
{ value: "today", label: i18n.locale() === 'cs' ? 'Dnes' : 'Today' },
{ value: "week", label: i18n.locale() === 'cs' ? 'Tento týden' : 'This Week' },
{ value: "month", label: i18n.locale() === 'cs' ? 'Tento měsíc' : 'This Month' },
]}
/>
</div>
</div>
</div>
{/* Bookings Table */}
<div class="surface-card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-canvas-subtle border-b border-border">
<tr>
<th class="text-left px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Zákazník' : 'Customer'}</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Služba' : 'Service'}</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Datum a čas' : 'Date & Time'}</th>
<th class="text-center px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Stav' : 'Status'}</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Reference' : 'Reference'}</th>
<th class="text-center px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Akce' : 'Actions'}</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
{filteredBookings().map((booking: any, index: number) => (
<tr
class="group hover:bg-canvas-subtle/50 transition-colors"
style={{ "animation-delay": `${index * 30}ms` }}
>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center text-sm font-bold text-accent">
{booking.avatar || getInitials(booking.customerName)}
</div>
<div>
<p class="font-medium text-ink">{booking.customerName}</p>
<p class="text-sm text-ink-muted">{booking.customerEmail}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<p class="font-medium text-ink">{booking.service}</p>
<p class="text-sm text-ink-muted">{booking.location}</p>
</td>
<td class="px-6 py-4">
<p class="text-ink">{new Date(booking.startsAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}</p>
<p class="text-sm text-ink-muted">{new Date(booking.startsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} - {new Date(booking.endsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</p>
</td>
<td class="px-6 py-4 text-center">
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${statusColors[booking.status] || 'bg-canvas-muted text-ink-muted'}`}>
{statusLabels[booking.status] || booking.status}
</span>
</td>
<td class="px-6 py-4 text-sm text-ink-muted font-mono">
{booking.reference}
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openBookingDetail(booking)}
class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors"
title={i18n.locale() === 'cs' ? 'Detail' : 'Details'}
>
<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="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7Z"/>
</svg>
</button>
{booking.status !== 'cancelled' && booking.status !== 'completed' && (
<>
<button
onClick={() => openBookingDetail(booking)}
class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors"
title={i18n.locale() === 'cs' ? 'Upravit' : 'Edit'}
>
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/>
</svg>
</button>
<button
onClick={() => handleCancelBooking(booking.id)}
class="p-2 text-ink-muted hover:text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))] rounded-lg transition-colors"
title={i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>
</svg>
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredBookings().length === 0 && (
<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">{i18n.locale() === 'cs' ? 'Žádné rezervace nenalezeny' : 'No bookings found'}</p>
</div>
)}
</div>
</div>
{/* New Booking Modal */}
{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 max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-border">
<h3 class="text-xl font-bold text-ink">{i18n.locale() === 'cs' ? 'Nová rezervace' : 'New Booking'}</h3>
</div>
<div class="p-6 space-y-4">
<Input
type="text"
label={i18n.locale() === 'cs' ? 'Jméno zákazníka' : 'Customer Name'}
value={newBookingCustomer()}
onInput={(e) => setNewBookingCustomer(e.currentTarget.value)}
placeholder={i18n.locale() === 'cs' ? 'např. Jan Novák' : 'e.g. John Doe'}
/>
<Input
type="email"
label={i18n.locale() === 'cs' ? 'Email' : 'Email'}
value={newBookingEmail()}
onInput={(e) => setNewBookingEmail(e.currentTarget.value)}
placeholder="customer@example.com"
/>
<Input
type="text"
label={i18n.locale() === 'cs' ? 'Služba' : 'Service'}
value={newBookingService()}
onInput={(e) => setNewBookingService(e.currentTarget.value)}
placeholder={i18n.locale() === 'cs' ? 'např. Masáž' : 'e.g. Massage'}
/>
<div class="grid grid-cols-2 gap-4">
<Input
type="date"
label={i18n.locale() === 'cs' ? 'Datum' : 'Date'}
value={newBookingDate()}
onInput={(e) => setNewBookingDate(e.currentTarget.value)}
/>
<Input
type="time"
label={i18n.locale() === 'cs' ? 'Čas' : 'Time'}
value={newBookingTime()}
onInput={(e) => setNewBookingTime(e.currentTarget.value)}
/>
</div>
<Textarea
label={i18n.locale() === 'cs' ? 'Poznámky' : 'Notes'}
value={newBookingNotes()}
onInput={(e) => setNewBookingNotes(e.currentTarget.value)}
rows={3}
resize="none"
placeholder={i18n.locale() === 'cs' ? 'Speciální požadavky...' : 'Special requests...'}
/>
</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"
>
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</button>
<button
onClick={handleCreateBooking}
disabled={!newBookingCustomer() || !newBookingEmail() || !newBookingDate() || !newBookingTime()}
class="flex-1 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{i18n.locale() === 'cs' ? 'Vytvořit rezervaci' : 'Create Booking'}
</button>
</div>
</div>
</div>
)}
{/* Booking Detail Modal */}
{showBookingDetail() && 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={() => setShowBookingDetail(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{i18n.locale() === 'cs' ? 'Detail rezervace' : 'Booking Details'}</h3>
<button onClick={() => setShowBookingDetail(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors">
<XIcon />
</button>
</div>
<div class="p-6 space-y-4">
{!editingBooking() ? (
<>
<div class="flex items-center gap-4 p-4 bg-canvas-subtle rounded-xl">
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center text-lg font-bold text-accent">
{selectedBooking().avatar || getInitials(selectedBooking().customerName)}
</div>
<div>
<p class="font-semibold text-ink text-lg">{selectedBooking().customerName}</p>
<p class="text-ink-muted">{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-sm text-ink-muted mb-1">{i18n.locale() === 'cs' ? 'Služba' : 'Service'}</p>
<p class="font-medium text-ink">{selectedBooking().service}</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-1">{i18n.locale() === 'cs' ? 'Místo' : 'Location'}</p>
<p class="font-medium text-ink">{selectedBooking().location || '-'}</p>
</div>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-1">{i18n.locale() === 'cs' ? 'Datum a čas' : 'Date & Time'}</p>
<p class="font-medium text-ink">{new Date(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">{new Date(selectedBooking().endsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</p>
</div>
<div class="flex items-center justify-between p-4 bg-canvas-subtle rounded-xl">
<div>
<p class="text-sm text-ink-muted mb-1">{i18n.locale() === 'cs' ? 'Reference' : 'Reference'}</p>
<p class="font-medium text-ink font-mono">{selectedBooking().reference}</p>
</div>
<span class={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium ${statusColors[selectedBooking().status]}`}>
{statusLabels[selectedBooking().status]}
</span>
</div>
{selectedBooking().notes && (
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-1">{i18n.locale() === 'cs' ? 'Poznámky' : 'Notes'}</p>
<p class="text-ink">{selectedBooking().notes}</p>
</div>
)}
</>
) : (
<>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">{i18n.locale() === 'cs' ? 'Jméno' : 'Name'}</label>
<input
type="text"
value={newBookingCustomer()}
onInput={(e) => setNewBookingCustomer(e.currentTarget.value)}
class="w-full px-4 py-2.5 bg-canvas-subtle border border-border rounded-xl text-ink focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">{i18n.locale() === 'cs' ? 'Email' : 'Email'}</label>
<input
type="email"
value={newBookingEmail()}
onInput={(e) => setNewBookingEmail(e.currentTarget.value)}
class="w-full px-4 py-2.5 bg-canvas-subtle border border-border rounded-xl text-ink focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">{i18n.locale() === 'cs' ? 'Služba' : 'Service'}</label>
<input
type="text"
value={newBookingService()}
onInput={(e) => setNewBookingService(e.currentTarget.value)}
class="w-full px-4 py-2.5 bg-canvas-subtle border border-border rounded-xl text-ink focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">{i18n.locale() === 'cs' ? 'Poznámky' : 'Notes'}</label>
<textarea
value={newBookingNotes()}
onInput={(e) => setNewBookingNotes(e.currentTarget.value)}
rows={3}
class="w-full px-4 py-2.5 bg-canvas-subtle border border-border rounded-xl text-ink focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all resize-none"
/>
</div>
</>
)}
</div>
<div class="p-6 border-t border-border flex gap-3">
{!editingBooking() ? (
<>
<button
onClick={() => setShowBookingDetail(false)}
class="flex-1 px-4 py-2.5 border border-border rounded-xl text-ink hover:bg-canvas-subtle transition-colors"
>
{i18n.locale() === 'cs' ? 'Zavřít' : 'Close'}
</button>
{selectedBooking().status !== 'cancelled' && selectedBooking().status !== 'completed' && (
<>
<button
onClick={() => setEditingBooking(true)}
class="px-4 py-2.5 bg-accent-subtle text-accent rounded-xl hover:bg-accent-soft transition-colors"
>
{i18n.locale() === 'cs' ? 'Upravit' : 'Edit'}
</button>
<button
onClick={() => {
handleCancelBooking(selectedBooking().id);
setShowBookingDetail(false);
}}
class="px-4 py-2.5 bg-[hsl(var(--error-soft))] text-[hsl(var(--error))] rounded-xl hover:bg-[hsl(var(--error-subtle))] transition-colors"
>
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</button>
</>
)}
</>
) : (
<>
<button
onClick={() => setEditingBooking(false)}
class="flex-1 px-4 py-2.5 border border-border rounded-xl text-ink hover:bg-canvas-subtle transition-colors"
>
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</button>
<button
onClick={handleUpdateBooking}
class="flex-1 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-colors"
>
{i18n.locale() === 'cs' ? 'Uložit změny' : 'Save Changes'}
</button>
</>
)}
</div>
</div>
</div>
)}
</div>
);
};
const BillingPage = () => (
<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">{i18n.locale() === 'cs' ? 'Platby' : 'Billing'}</h1>
<p class="text-ink-muted mt-1">{i18n.locale() === 'cs' ? 'Spravujte své předplatné' : 'Manage your subscription'}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Plan Card */}
<div class="lg:col-span-2 relative overflow-hidden rounded-card">
<div class="absolute inset-0 bg-gradient-to-br from-ink via-ink/95 to-ink" />
<div class="absolute inset-0 bg-gradient-to-tr from-accent/10 via-transparent to-[hsl(210,60%,50%,0.1)]" />
<div class="relative p-8 text-canvas">
<div class="flex items-start justify-between">
<div>
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-canvas/10 backdrop-blur text-xs font-medium mb-4">
<SparklesIcon /> {i18n.locale() === 'cs' ? 'Aktuální plán' : 'Current Plan'}
</div>
<h2 class="text-4xl font-bold capitalize tracking-tight">{resolvedBilling().planCode}</h2>
<p class="text-canvas/60 mt-2 text-lg">{billingPriceLabel()}<span class="text-sm text-canvas/40">/{i18n.locale() === 'cs' ? 'měsíc' : 'month'}</span></p>
</div>
<div class="flex flex-col items-end gap-3">
<span class="inline-flex items-center px-4 py-1.5 rounded-full bg-[hsl(var(--success))/20] text-[hsl(var(--success))] text-sm font-semibold backdrop-blur">
{resolvedBilling().status}
</span>
</div>
</div>
<Show when={billingNotice()}>
<div class="mt-6 rounded-xl border border-[hsl(var(--success))/20] bg-[hsl(var(--success))/10] px-4 py-3 text-sm text-[hsl(var(--success))]">
{billingNotice()}
</div>
</Show>
<Show when={billingError()}>
<div class="mt-6 rounded-xl border border-[hsl(var(--error))/20] bg-[hsl(var(--error))/10] px-4 py-3 text-sm text-[hsl(var(--error))]">
{billingError()}
</div>
</Show>
<div class="mt-8 flex items-center gap-4 flex-wrap">
<button
onClick={() => void openCheckout()}
disabled={billingAction() !== null || !resolvedBilling().checkoutUrlAvailable}
class="bg-canvas text-ink px-6 py-2.5 rounded-xl font-semibold hover:bg-canvas/90 hover:shadow-lg hover:-translate-y-0.5 active:translate-y-0 transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0"
>
{billingAction() === "checkout" ? (i18n.locale() === 'cs' ? 'Otevírání...' : 'Opening...') : i18n.t("dashboard.checkout")}
</button>
<button
onClick={() => void openBillingPortal()}
disabled={billingAction() !== null || resolvedBilling().portalAvailable === false}
class="text-canvas/60 hover:text-canvas transition-colors px-4 py-2.5 disabled:cursor-not-allowed disabled:opacity-60"
>
{billingAction() === "portal" ? (i18n.locale() === 'cs' ? 'Otevírání portálu...' : 'Opening portal...') : (i18n.locale() === "cs" ? "Spravovat předplatné" : "Manage subscription")}
</button>
<button
onClick={() => void refreshBilling()}
disabled={billingAction() !== null || resolvedBilling().syncAvailable === false}
class="text-canvas/60 hover:text-canvas transition-colors px-4 py-2.5 disabled:cursor-not-allowed disabled:opacity-60"
>
{billingAction() === "refresh" ? (i18n.locale() === 'cs' ? 'Obnovování...' : 'Refreshing...') : i18n.t("dashboard.refreshBilling")}
</button>
</div>
</div>
</div>
{/* Next Billing */}
<div class="surface-card p-6 hover:shadow-lg transition-shadow">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent">
<ClockIcon />
</div>
<p class="text-sm font-semibold text-ink-muted uppercase tracking-wide">{i18n.locale() === 'cs' ? 'Další platba' : 'Next Billing'}</p>
</div>
<p class="text-3xl font-bold text-ink">{nextBillingLabel()}</p>
<p class="text-ink-muted mt-1">{billingPriceLabel()} {i18n.locale() === 'cs' ? 'bude strženo' : 'will be charged'}</p>
<div class="mt-4 pt-4 border-t border-border">
<div class="flex items-center justify-between text-sm">
<span class="text-ink-muted">{i18n.locale() === 'cs' ? 'Způsob platby' : 'Payment method'}</span>
<span class="font-medium text-ink">{paymentMethodLabel()}</span>
</div>
</div>
</div>
</div>
{/* Features */}
<div class="surface-card p-6">
<h3 class="text-lg font-semibold text-ink mb-5">{i18n.locale() === 'cs' ? 'Zahrnuté funkce' : 'Included Features'}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.entries(resolvedBilling().entitlements).map(([key, value], index) => (
<div
class="group flex items-center gap-4 p-4 rounded-xl bg-canvas-subtle hover:bg-canvas hover:shadow-md border border-transparent hover:border-border transition-all duration-300"
style={{ "animation-delay": `${index * 50}ms` }}
>
<div class="w-12 h-12 rounded-full bg-[hsl(var(--success-soft))] flex items-center justify-center text-[hsl(var(--success))] group-hover:scale-110 transition-transform">
<CheckCircleIcon />
</div>
<div>
<p class="font-semibold text-ink capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}</p>
<p class="text-sm text-ink-muted">{typeof value === 'boolean' ? (value ? (i18n.locale() === 'cs' ? 'Zapnuto' : 'Enabled') : (i18n.locale() === 'cs' ? 'Vypnuto' : 'Disabled')) : String(value)}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
const SettingsPage = () => {
// Brand settings state
const [brandColor, setBrandColor] = createSignal(resolvedBootstrap().brand?.primaryColor || "#a65c3e");
const [businessName, setBusinessName] = createSignal(resolvedBootstrap().tenantName || "");
const [businessEmail, setBusinessEmail] = createSignal("");
const [businessPhone, setBusinessPhone] = createSignal("");
const [businessAddress, setBusinessAddress] = createSignal("");
const [emailTemplate, setEmailTemplate] = createSignal("default");
const [activeEmailType, setActiveEmailType] = createSignal<"confirmation" | "reminder" | "cancellation" | "reschedule">("confirmation");
const [showEmailPreview, setShowEmailPreview] = createSignal(false);
const [emailSettings, setEmailSettings] = createSignal({
sendConfirmation: true,
sendReminder: true,
reminderHours: 24,
sendCancellation: true,
sendReschedule: true,
includeManageLink: true,
includeCalendarLink: true,
includeChatLink: true,
});
const handleSaveBrand = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) return;
// Save brand settings to backend
await (apiClient as any).PUT("/v1/tenants/brand", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
primaryColor: brandColor(),
name: businessName()
}
});
};
const presetColors = [
{ color: "#a65c3e", name: i18n.locale() === 'cs' ? 'Terra' : 'Terra' },
{ color: "#2563eb", name: i18n.locale() === 'cs' ? 'Ocean' : 'Ocean' },
{ color: "#059669", name: i18n.locale() === 'cs' ? 'Forest' : 'Forest' },
{ color: "#7c3aed", name: i18n.locale() === 'cs' ? 'Royal' : 'Royal' },
{ color: "#dc2626", name: i18n.locale() === 'cs' ? 'Ruby' : 'Ruby' },
{ color: "#ea580c", name: i18n.locale() === 'cs' ? 'Sunset' : 'Sunset' },
{ color: "#0891b2", name: i18n.locale() === 'cs' ? 'Cyan' : 'Cyan' },
{ color: "#475569", name: i18n.locale() === 'cs' ? 'Slate' : 'Slate' },
];
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">{i18n.locale() === 'cs' ? 'Nastavení' : 'Settings'}</h1>
<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">
{/* Business Information */}
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
<LayoutDashboardIcon />
</div>
<h3 class="text-lg font-semibold text-ink">{i18n.locale() === 'cs' ? 'Informace o podniku' : 'Business Information'}</h3>
</div>
<div class="space-y-4">
<Input
type="text"
label={i18n.locale() === 'cs' ? 'Název podniku' : 'Business Name'}
value={businessName()}
onInput={(e) => setBusinessName(e.currentTarget.value)}
/>
<Input
type="email"
label={i18n.locale() === 'cs' ? 'Kontaktní email' : 'Contact Email'}
value={businessEmail()}
onInput={(e) => setBusinessEmail(e.currentTarget.value)}
placeholder="info@yourbusiness.com"
/>
<Input
type="tel"
label={i18n.locale() === 'cs' ? 'Telefon' : 'Phone'}
value={businessPhone()}
onInput={(e) => setBusinessPhone(e.currentTarget.value)}
placeholder="+420 123 456 789"
/>
<Textarea
label={i18n.locale() === 'cs' ? 'Adresa' : 'Address'}
value={businessAddress()}
onInput={(e) => setBusinessAddress(e.currentTarget.value)}
rows={2}
placeholder={i18n.locale() === 'cs' ? 'Vaše adresa...' : 'Your address...'}
resize="none"
/>
</div>
</div>
{/* Branding Settings */}
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center text-accent">
<PaletteIcon />
</div>
<h3 class="text-lg font-semibold text-ink">{i18n.locale() === 'cs' ? 'Branding a vzhled' : 'Branding & Appearance'}</h3>
</div>
{/* Color Picker */}
<div class="mb-6">
<label class="block text-sm font-medium text-ink-muted mb-3">{i18n.locale() === 'cs' ? 'Barva značky' : 'Brand Color'}</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>
{/* Preview */}
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-3">{i18n.locale() === 'cs' ? 'Náhled widgetu' : 'Widget 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-white font-bold"
style={{ background: brandColor() }}
>
{businessName().charAt(0) || 'B'}
</div>
<div>
<p class="font-semibold text-ink">{businessName() || (i18n.locale() === 'cs' ? 'Váš podnik' : 'Your Business')}</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-white font-medium text-sm"
style={{ background: brandColor() }}
>
{i18n.locale() === 'cs' ? 'Rezervovat' : 'Book Now'}
</button>
</div>
</div>
<button
onClick={handleSaveBrand}
class="mt-4 w-full py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-colors font-medium"
>
{i18n.locale() === 'cs' ? 'Uložit změny' : 'Save Changes'}
</button>
</div>
</div>
{/* Email Notifications Section */}
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
<MailIcon />
</div>
<div>
<h3 class="text-lg font-semibold text-ink">{i18n.locale() === 'cs' ? 'Emailová oznámení' : 'Email Notifications'}</h3>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Přizpůsobte emaily posílané zákazníkům' : 'Customize emails sent to customers'}</p>
</div>
</div>
{/* Email Type Tabs */}
<div class="flex gap-2 mb-4 overflow-x-auto pb-2">
{[
{ key: "confirmation", label: i18n.locale() === 'cs' ? 'Potvrzení' : 'Confirmation', icon: CheckCircleIcon },
{ key: "reminder", label: i18n.locale() === 'cs' ? 'Připomínka' : 'Reminder', icon: BellIcon },
{ key: "cancellation", label: i18n.locale() === 'cs' ? 'Zrušení' : 'Cancellation', icon: XCircleIcon },
{ key: "reschedule", label: i18n.locale() === 'cs' ? 'Změna' : 'Reschedule', icon: CalendarIcon },
].map((type) => (
<button
onClick={() => setActiveEmailType(type.key as any)}
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-white'
: 'bg-canvas-subtle text-ink-muted hover:text-ink'
}`}
>
<type.icon />
{type.label}
</button>
))}
</div>
<div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Select
label={i18n.locale() === 'cs' ? 'Šablona stylu' : 'Template Style'}
value={emailTemplate()}
onChange={(v) => setEmailTemplate(v)}
options={[
{ value: "default", label: i18n.locale() === 'cs' ? 'Výchozí (Bookra)' : 'Default (Bookra)' },
{ value: "minimal", label: i18n.locale() === 'cs' ? 'Minimální' : 'Minimal' },
{ value: "professional", label: i18n.locale() === 'cs' ? 'Profesionální' : 'Professional' },
{ value: "friendly", label: i18n.locale() === 'cs' ? 'Přátelská' : 'Friendly' },
]}
/>
<Select
label={i18n.locale() === 'cs' ? 'Připomínka před' : 'Reminder Before'}
value={String(emailSettings().reminderHours)}
onChange={(v) => setEmailSettings({ ...emailSettings(), reminderHours: Number(v) })}
options={[
{ value: "2", label: '2 ' + (i18n.locale() === 'cs' ? 'hodiny' : 'hours') },
{ value: "12", label: '12 ' + (i18n.locale() === 'cs' ? 'hodin' : 'hours') },
{ value: "24", label: '24 ' + (i18n.locale() === 'cs' ? 'hodin' : 'hours') },
{ value: "48", label: '48 ' + (i18n.locale() === 'cs' ? 'hodin' : 'hours') },
{ value: "72", label: '72 ' + (i18n.locale() === 'cs' ? 'hodin' : 'hours') },
]}
/>
</div>
{/* Email Settings Toggles */}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-4 bg-canvas-subtle rounded-xl">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={emailSettings().sendConfirmation}
onChange={(e) => setEmailSettings({ ...emailSettings(), sendConfirmation: e.currentTarget.checked })}
class="w-4 h-4 rounded border-border text-accent focus:ring-accent"
/>
<span class="text-sm text-ink">{i18n.locale() === 'cs' ? 'Odeslat potvrzení' : 'Send confirmation'}</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={emailSettings().sendReminder}
onChange={(e) => setEmailSettings({ ...emailSettings(), sendReminder: e.currentTarget.checked })}
class="w-4 h-4 rounded border-border text-accent focus:ring-accent"
/>
<span class="text-sm text-ink">{i18n.locale() === 'cs' ? 'Odeslat připomínku' : 'Send reminder'}</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={emailSettings().includeManageLink}
onChange={(e) => setEmailSettings({ ...emailSettings(), includeManageLink: e.currentTarget.checked })}
class="w-4 h-4 rounded border-border text-accent focus:ring-accent"
/>
<span class="text-sm text-ink">{i18n.locale() === 'cs' ? 'Odkaz pro správu' : 'Management link'}</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={emailSettings().includeChatLink}
onChange={(e) => setEmailSettings({ ...emailSettings(), includeChatLink: e.currentTarget.checked })}
class="w-4 h-4 rounded border-border text-accent focus:ring-accent"
/>
<span class="text-sm text-ink">{i18n.locale() === 'cs' ? 'Chat s podnikem' : 'Chat with business'}</span>
</label>
</div>
<div class="flex items-center justify-between p-4 bg-canvas-subtle rounded-xl">
<div>
<p class="font-medium text-ink">
{activeEmailType() === 'confirmation' ? (i18n.locale() === 'cs' ? 'Náhled potvrzení' : 'Confirmation Preview') :
activeEmailType() === 'reminder' ? (i18n.locale() === 'cs' ? 'Náhled připomínky' : 'Reminder Preview') :
activeEmailType() === 'cancellation' ? (i18n.locale() === 'cs' ? 'Náhled zrušení' : 'Cancellation Preview') :
i18n.locale() === 'cs' ? 'Náhled změny' : 'Reschedule Preview'}
</p>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Zobrazit ukázku emailu' : 'See how emails look'}</p>
</div>
<button
onClick={() => setShowEmailPreview(true)}
class="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent-hover transition-colors text-sm"
>
{i18n.locale() === 'cs' ? 'Zobrazit náhled' : 'Preview'}
</button>
</div>
</div>
</div>
{/* Email Preview Modal */}
{showEmailPreview() && (
<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={() => setShowEmailPreview(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-border flex items-center justify-between">
<div>
<h3 class="text-xl font-bold text-ink">
{activeEmailType() === 'confirmation' ? (i18n.locale() === 'cs' ? 'Náhled: Potvrzení' : 'Preview: Confirmation') :
activeEmailType() === 'reminder' ? (i18n.locale() === 'cs' ? 'Náhled: Připomínka' : 'Preview: Reminder') :
activeEmailType() === 'cancellation' ? (i18n.locale() === 'cs' ? 'Náhled: Zrušení' : 'Preview: Cancellation') :
i18n.locale() === 'cs' ? 'Náhled: Změna termínu' : 'Preview: Reschedule'}
</h3>
<p class="text-sm text-ink-muted mt-1">
{i18n.locale() === 'cs' ? 'Šablona: ' : 'Template: '}
{emailTemplate() === 'default' ? (i18n.locale() === 'cs' ? 'Výchozí' : 'Default') :
emailTemplate() === 'minimal' ? (i18n.locale() === 'cs' ? 'Minimální' : 'Minimal') :
emailTemplate() === 'professional' ? (i18n.locale() === 'cs' ? 'Profesionální' : 'Professional') :
i18n.locale() === 'cs' ? 'Přátelská' : 'Friendly'}
</p>
</div>
<button onClick={() => setShowEmailPreview(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors">
<XIcon />
</button>
</div>
<div class="p-6">
<div class={`rounded-xl border border-border overflow-hidden ${theme.resolvedTheme() === 'dark' ? 'dark' : ''}`}>
{/* Email Header */}
<div class="p-6 border-b border-border" style={{ background: brandColor() + '15' }}>
<div class="flex items-center gap-3">
<div
class="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold shadow-lg"
style={{ background: brandColor() }}
>
{businessName().charAt(0) || 'B'}
</div>
<div>
<p class="font-semibold text-ink">{businessName() || (i18n.locale() === 'cs' ? 'Váš podnik' : 'Your Business')}</p>
<p class="text-sm text-ink-muted">
{activeEmailType() === 'confirmation' ? (i18n.locale() === 'cs' ? 'Potvrzení rezervace' : 'Booking Confirmation') :
activeEmailType() === 'reminder' ? (i18n.locale() === 'cs' ? 'Připomínka rezervace' : 'Booking Reminder') :
activeEmailType() === 'cancellation' ? (i18n.locale() === 'cs' ? 'Rezervace zrušena' : 'Booking Cancelled') :
i18n.locale() === 'cs' ? 'Změna rezervace' : 'Booking Rescheduled'}
</p>
</div>
</div>
</div>
{/* Email Body - Dynamic based on type */}
<div class="p-6 space-y-4 bg-white dark:bg-canvas">
<p class="text-ink">{i18n.locale() === 'cs' ? 'Dobrý den,' : 'Hello,'} Alice Johnson</p>
{/* Dynamic Message */}
<p class="text-ink-muted">
{activeEmailType() === 'confirmation' ?
(i18n.locale() === 'cs' ? 'Vaše rezervace byla úspěšně potvrzena. Těšíme se na vás!' : 'Your booking has been confirmed. We look forward to seeing you!') :
activeEmailType() === 'reminder' ?
(i18n.locale() === 'cs' ? `Vaše rezervace je naplánována za ${emailSettings().reminderHours} hodin.` : `Your booking is scheduled in ${emailSettings().reminderHours} hours.`) :
activeEmailType() === 'cancellation' ?
(i18n.locale() === 'cs' ? 'Vaše rezervace byla zrušena podle vaší žádosti.' : 'Your booking has been cancelled as requested.') :
(i18n.locale() === 'cs' ? 'Váš termín rezervace byl změněn. Zde jsou nové detaily:' : 'Your booking has been rescheduled. Here are the new details:')}
</p>
{/* Show previous date for reschedule */}
{activeEmailType() === 'reschedule' && (
<div class="p-3 bg-[hsl(var(--warning-subtle))] rounded-lg">
<p class="text-sm text-[hsl(var(--warning))]">
{i18n.locale() === 'cs' ? 'Předchozí termín: ' : 'Previous: '} Friday, Jan 12, 2025 at 2:00 PM
</p>
</div>
)}
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-1">{i18n.locale() === 'cs' ? 'Služba' : 'Service'}</p>
<p class="font-medium text-ink">Yoga Flow Class</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-1">{i18n.locale() === 'cs' ? 'Datum a čas' : 'Date & Time'}</p>
<p class="font-medium text-ink">
{activeEmailType() === 'reschedule' ?
(i18n.locale() === 'cs' ? 'NOVÝ TERMÍN: ' : 'NEW TIME: ') : ''}
Monday, Jan 15, 2025 at 10:00 AM
</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-1">{i18n.locale() === 'cs' ? 'Místo' : 'Location'}</p>
<p class="font-medium text-ink">{businessName() || (i18n.locale() === 'cs' ? 'Váš podnik' : 'Your Business')}</p>
<p class="text-sm text-ink-muted">{businessAddress() || (i18n.locale() === 'cs' ? 'Vaše adresa' : 'Your Address')}</p>
</div>
{/* Action Buttons */}
<Show when={emailSettings().includeManageLink}>
<div class="flex gap-3 pt-4">
<button
class="flex-1 py-2.5 rounded-lg text-white font-medium text-sm shadow-md transition-transform hover:scale-[1.02]"
style={{ background: brandColor() }}
>
{activeEmailType() === 'cancellation' ?
(i18n.locale() === 'cs' ? 'Vytvořit novou rezervaci' : 'Book Again') :
activeEmailType() === 'reschedule' ?
(i18n.locale() === 'cs' ? 'Potvrdit změnu' : 'Confirm Change') :
(i18n.locale() === 'cs' ? 'Spravovat rezervaci' : 'Manage Booking')}
</button>
<Show when={emailSettings().includeCalendarLink && activeEmailType() !== 'cancellation'}>
<button class="flex-1 py-2.5 border border-border rounded-lg text-ink font-medium text-sm hover:bg-canvas-subtle transition-colors">
{i18n.locale() === 'cs' ? 'Přidat do kalendáře' : 'Add to Calendar'}
</button>
</Show>
</div>
</Show>
{/* Chat Link */}
<Show when={emailSettings().includeChatLink && activeEmailType() !== 'cancellation'}>
<p class="text-center text-sm text-ink-muted pt-2">
{i18n.locale() === 'cs' ? 'Máte otázky? ' : 'Have questions? '}
<a href="#" class="text-accent hover:underline">
{i18n.locale() === 'cs' ? 'Kontaktujte nás' : 'Contact us'}
</a>
</p>
</Show>
</div>
{/* Email Footer */}
<div class="p-6 bg-canvas-subtle text-center">
<p class="text-xs text-ink-muted">
{i18n.locale() === 'cs' ? 'Děkujeme, že používáte naše služby.' : 'Thank you for choosing us.'}
</p>
<p class="text-xs text-ink-muted mt-1">{businessEmail() || 'contact@business.com'}</p>
<p class="text-xs text-ink-subtle mt-3">
{i18n.locale() === 'cs' ? 'Odesláno přes Bookra' : 'Powered by Bookra'}
</p>
</div>
</div>
{/* Dark Mode Toggle for Preview */}
<div class="mt-4 flex justify-center">
<button
onClick={() => theme.toggle()}
class="flex items-center gap-2 px-4 py-2 text-sm text-ink-muted hover:text-ink transition-colors"
>
{theme.resolvedTheme() === 'dark' ? <SunIcon /> : <MoonIcon />}
{theme.resolvedTheme() === 'dark'
? (i18n.locale() === 'cs' ? 'Náhled ve světlém režimu' : 'Preview in light mode')
: (i18n.locale() === 'cs' ? 'Náhled v tmavém režimu' : 'Preview in dark mode')}
</button>
</div>
</div>
</div>
</div>
)}
{/* Widget Builder */}
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-[hsl(270_40%_20%)] flex items-center justify-center text-[hsl(270_50%_60%)]">
<SparklesIcon />
</div>
<div>
<h3 class="text-lg font-semibold text-ink">{i18n.locale() === 'cs' ? 'Widget pro rezervace' : 'Booking Widget'}</h3>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Integrujte rezervace na váš web' : 'Integrate booking to your website'}</p>
</div>
</div>
<WidgetBuilder config={{
tenantSlug: resolvedSummary()?.tenantSlug || "demo-studio",
publicBookingUrl: resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio",
tenantName: resolvedSummary()?.tenantName || "Demo Studio",
primaryColor: brandColor()
}} />
</div>
</div>
);
};
// ==========================================
// SIDEBAR COMPONENT
// ==========================================
const Sidebar = () => (
<aside class="hidden lg:flex flex-col w-72 h-screen sticky top-0 border-r border-border bg-canvas/80 backdrop-blur-xl">
<div class="p-6 border-b border-border">
<A href="/" class="inline-flex items-center gap-3 group">
<img src="/bookra-logo.svg" alt="Bookra" class="h-9 w-auto transition-transform group-hover:scale-105 dark:invert" />
</A>
<Show when={isDemoMode()}>
<div class="mt-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-gradient-to-r from-[hsl(var(--warning-subtle))] to-[hsl(var(--warning-soft))] text-[hsl(var(--warning))] text-xs font-semibold shadow-sm">
<SparklesIcon /> {i18n.locale() === 'cs' ? 'Demo režim' : 'Demo Mode'}
</div>
</Show>
</div>
<nav class="flex-1 p-4 space-y-1">
{navItems.map((item) => (
<button
onClick={() => changeSection(item.id)}
class={`
group w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 relative overflow-hidden
${activeSection() === item.id
? "bg-gradient-to-r from-accent-subtle to-accent-soft text-accent shadow-sm"
: "text-ink-muted hover:text-ink hover:bg-canvas-subtle/80"
}
`}
>
{/* Active indicator */}
{activeSection() === item.id && (
<div class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-accent rounded-r-full" />
)}
<span class={`transition-transform duration-300 ${activeSection() === item.id ? 'translate-x-1' : 'group-hover:translate-x-0.5'}`}>
<item.icon />
</span>
<span class="relative">{item.label}</span>
</button>
))}
</nav>
<div class="p-4 border-t border-border space-y-2">
{/* Theme Toggle */}
<button
onClick={() => theme.toggle()}
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/80 transition-all duration-300 group"
>
<span class="transition-transform group-hover:scale-110">
{theme.resolvedTheme() === 'dark' ? <SunIcon /> : <MoonIcon />}
</span>
{theme.resolvedTheme() === 'dark'
? (i18n.locale() === 'cs' ? 'Světlý režim' : 'Light Mode')
: (i18n.locale() === 'cs' ? 'Tmavý režim' : 'Dark Mode')
}
</button>
<Show when={auth.session()}>
<div class="flex items-center gap-3 p-3 rounded-xl bg-canvas-subtle/80 hover:bg-canvas-muted transition-colors cursor-pointer">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center shadow-sm">
<span class="font-bold text-accent text-sm">{auth.session()?.user?.name?.charAt(0) || 'U'}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-ink truncate">{auth.session()?.user?.name}</p>
<p class="text-xs text-ink-muted truncate">{auth.session()?.user?.email}</p>
</div>
</div>
</Show>
<button
onClick={() => void 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-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))] transition-all duration-300 group"
>
<span class="group-hover:translate-x-0.5 transition-transform"><LogOutIcon /></span>
{i18n.t("auth.signOut")}
</button>
</div>
</aside>
);
// ==========================================
// MOBILE MENU
// ==========================================
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 transform transition-transform">
<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" />
<button
onClick={() => setIsMobileMenuOpen(false)}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-colors"
>
<XIcon />
</button>
</div>
<nav class="p-4 space-y-1">
{navItems.map((item) => (
<button
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
${activeSection() === item.id
? "bg-accent-subtle text-accent"
: "text-ink-muted hover:bg-canvas-subtle"
}
`}
>
<item.icon /> {item.label}
</button>
))}
</nav>
</div>
</div>
</Show>
);
// ==========================================
// CUSTOMERS PAGE
// ==========================================
const CustomersPage = () => {
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedCustomer, setSelectedCustomer] = createSignal<any>(null);
const [showCustomerDetail, setShowCustomerDetail] = createSignal(false);
const [filterStatus, setFilterStatus] = createSignal<"all" | "active" | "vip" | "inactive">("all");
const [customers, { refetch: refetchCustomers }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return getDemoCustomers();
const response = await (apiClient as any).GET("/v1/catalog/customers", {
headers: { Authorization: `Bearer ${bearer}` }
});
return response.data ?? [];
});
// Get all bookings for linking
const [allBookings] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return demoData().summary.allBookings || [];
const response = await (apiClient as any).GET("/v1/catalog/bookings", {
headers: { Authorization: `Bearer ${bearer}` }
});
return response.data ?? [];
});
const getDemoCustomers = () => [
{ id: "1", name: "Alice Johnson", email: "alice@example.com", phone: "+420 123 456 789", bookingsCount: 5, lastBookingAt: new Date(Date.now() - 86400000).toISOString(), status: "active", notes: "Allergic to strong scents" },
{ id: "2", name: "Bob Smith", email: "bob@example.com", phone: "+420 987 654 321", bookingsCount: 3, lastBookingAt: new Date(Date.now() - 172800000).toISOString(), status: "active", notes: "" },
{ id: "3", name: "Carol White", email: "carol@example.com", phone: "+420 555 666 777", bookingsCount: 8, lastBookingAt: new Date(Date.now() - 259200000).toISOString(), status: "vip", notes: "Prefers morning slots" },
{ id: "4", name: "David Brown", email: "david@example.com", phone: "+420 111 222 333", bookingsCount: 1, lastBookingAt: new Date(Date.now() - 604800000).toISOString(), status: "inactive", notes: "" },
{ id: "5", name: "Emma Wilson", email: "emma@example.com", phone: "+420 444 555 666", bookingsCount: 12, lastBookingAt: new Date(Date.now() - 432000000).toISOString(), status: "vip", notes: "Loyal customer since 2020" },
];
const resolvedCustomers = () => (customers.latest as any[]) ?? getDemoCustomers();
const resolvedBookings = () => (allBookings.latest as any[]) ?? demoData().summary.allBookings ?? [];
// Filter customers
const filteredCustomers = createMemo(() => {
let result = resolvedCustomers();
// Status filter
if (filterStatus() !== "all") {
result = result.filter((c: any) => c.status === filterStatus());
}
// Search filter
if (searchQuery().trim()) {
const query = searchQuery().toLowerCase();
result = result.filter((c: any) =>
c.name?.toLowerCase().includes(query) ||
c.email?.toLowerCase().includes(query) ||
c.phone?.toLowerCase().includes(query)
);
}
// Sort by last booking, newest first
return result.sort((a: any, b: any) => {
if (!a.lastBookingAt) return 1;
if (!b.lastBookingAt) return -1;
return new Date(b.lastBookingAt).getTime() - new Date(a.lastBookingAt).getTime();
});
});
// Get customer bookings
const getCustomerBookings = (customerEmail: string) => {
return resolvedBookings()
.filter((b: any) => b.customerEmail?.toLowerCase() === customerEmail?.toLowerCase())
.sort((a: any, b: any) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime());
};
const openCustomerDetail = (customer: any) => {
setSelectedCustomer(customer);
setShowCustomerDetail(true);
};
const statusColors: Record<string, string> = {
active: "bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]",
vip: "bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]",
inactive: "bg-canvas-muted text-ink-muted"
};
const statusLabels: Record<string, string> = {
active: i18n.locale() === 'cs' ? 'Aktivní' : 'Active',
vip: "VIP",
inactive: i18n.locale() === 'cs' ? 'Neaktivní' : 'Inactive'
};
const bookingStatusColors: Record<string, string> = {
confirmed: "bg-[hsl(var(--success-subtle))]",
pending: "bg-[hsl(var(--warning-subtle))]",
cancelled: "bg-[hsl(var(--error-subtle))]",
completed: "bg-canvas-muted"
};
return (
<div class={`space-y-6 transition-opacity duration-200 ${isPageTransitioning() ? 'opacity-0' : 'opacity-100'}`}>
{/* 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">{i18n.locale() === 'cs' ? 'Zákazníci' : 'Customers'}</h1>
<p class="text-ink-muted mt-1">{filteredCustomers().length} {i18n.locale() === 'cs' ? 'zákazníků' : 'customers'}</p>
</div>
<div class="flex gap-2">
<Select
value={filterStatus()}
onChange={(v) => setFilterStatus(v as any)}
class="min-w-[130px]"
options={[
{ value: "all", label: i18n.locale() === 'cs' ? 'Všichni' : 'All' },
{ value: "active", label: i18n.locale() === 'cs' ? 'Aktivní' : 'Active' },
{ value: "vip", label: "VIP" },
{ value: "inactive", label: i18n.locale() === 'cs' ? 'Neaktivní' : 'Inactive' },
]}
/>
</div>
</div>
{/* Search */}
<div class="surface-card p-4">
<Input
type="text"
placeholder={i18n.locale() === 'cs' ? 'Hledat podle jména, emailu nebo telefonu...' : 'Search by name, email, or phone...'}
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
/>
</div>
{/* Customers Table */}
<div class="surface-card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-canvas-subtle border-b border-border">
<tr>
<th class="text-left px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Jméno' : 'Name'}</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Kontakt' : 'Contact'}</th>
<th class="text-center px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Rezervace' : 'Bookings'}</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Poslední návštěva' : 'Last Visit'}</th>
<th class="text-center px-6 py-4 text-sm font-semibold text-ink-muted">{i18n.locale() === 'cs' ? 'Status' : 'Status'}</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
{filteredCustomers().map((customer: any, index: number) => (
<tr
class="group hover:bg-canvas-subtle/50 transition-colors cursor-pointer"
onClick={() => openCustomerDetail(customer)}
style={{ "animation-delay": `${index * 50}ms` }}
>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center text-sm font-bold text-accent">
{customer.name.split(' ').map((n: string) => n[0]).join('')}
</div>
<span class="font-medium text-ink">{customer.name}</span>
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-ink-muted">
<p>{customer.email}</p>
<p class="text-ink-subtle">{customer.phone}</p>
</div>
</td>
<td class="px-6 py-4 text-center">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-sm font-medium bg-[hsl(var(--info-soft))] text-[hsl(var(--info))]">
{customer.bookingsCount}
</span>
</td>
<td class="px-6 py-4 text-sm text-ink-muted">
{customer.lastBookingAt ? new Date(customer.lastBookingAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '-'}
</td>
<td class="px-6 py-4 text-center">
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${statusColors[customer.status]}`}>
{statusLabels[customer.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
{filteredCustomers().length === 0 && (
<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">{i18n.locale() === 'cs' ? 'Žádní zákazníci nenalezeni' : 'No customers found'}</p>
</div>
)}
</div>
</div>
{/* Customer Detail Modal */}
{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-2xl max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{i18n.locale() === 'cs' ? 'Detail zákazníka' : 'Customer Details'}</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-6">
{/* Customer Info */}
<div class="flex items-start gap-4 p-4 bg-canvas-subtle rounded-xl">
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center text-xl font-bold text-accent">
{selectedCustomer().name.split(' ').map((n: string) => n[0]).join('')}
</div>
<div class="flex-1">
<div class="flex items-center gap-3">
<h4 class="font-semibold text-ink text-lg">{selectedCustomer().name}</h4>
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[selectedCustomer().status]}`}>
{statusLabels[selectedCustomer().status]}
</span>
</div>
<p class="text-ink-muted">{selectedCustomer().email}</p>
<p class="text-ink-subtle text-sm">{selectedCustomer().phone}</p>
{selectedCustomer().notes && (
<p class="mt-2 text-sm text-ink-muted bg-canvas p-2 rounded-lg">{selectedCustomer().notes}</p>
)}
</div>
</div>
{/* Booking Stats */}
<div class="grid grid-cols-3 gap-4">
<div class="p-4 bg-canvas-subtle rounded-xl text-center">
<p class="text-2xl font-bold text-ink">{selectedCustomer().bookingsCount}</p>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Celkem rezervací' : 'Total Bookings'}</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl text-center">
<p class="text-2xl font-bold text-[hsl(var(--success))]">
{getCustomerBookings(selectedCustomer().email).filter((b: any) => b.status === 'confirmed').length}
</p>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed'}</p>
</div>
<div class="p-4 bg-canvas-subtle rounded-xl text-center">
<p class="text-2xl font-bold text-ink">
{selectedCustomer().lastBookingAt ? new Date(selectedCustomer().lastBookingAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '-'}
</p>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Poslední návštěva' : 'Last Visit'}</p>
</div>
</div>
{/* Booking History */}
<div>
<h5 class="font-semibold text-ink mb-4">{i18n.locale() === 'cs' ? 'Historie rezervací' : 'Booking History'}</h5>
<div class="space-y-3 max-h-[300px] overflow-y-auto">
{getCustomerBookings(selectedCustomer().email).length > 0 ? (
getCustomerBookings(selectedCustomer().email).map((booking: any, index: number) => (
<div
class="flex items-center gap-4 p-3 bg-canvas-subtle rounded-xl border border-border"
style={{ "animation-delay": `${index * 50}ms` }}
>
<div class={`w-2 h-12 rounded-full ${bookingStatusColors[booking.status] || 'bg-canvas-muted'}`} />
<div class="flex-1">
<p class="font-medium text-ink">{booking.service}</p>
<p class="text-sm text-ink-muted">
{new Date(booking.startsAt).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{' • '}
{booking.location}
</p>
</div>
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
booking.status === 'confirmed' ? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]' :
booking.status === 'pending' ? 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]' :
booking.status === 'cancelled' ? 'bg-[hsl(var(--error-soft))] text-[hsl(var(--error))]' :
'bg-canvas-muted text-ink-muted'
}`}>
{booking.status === 'confirmed' ? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed') :
booking.status === 'pending' ? (i18n.locale() === 'cs' ? 'Čeká' : 'Pending') :
booking.status === 'cancelled' ? (i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled') :
i18n.locale() === 'cs' ? 'Dokončeno' : 'Completed'}
</span>
</div>
))
) : (
<p class="text-ink-muted text-center py-4">{i18n.locale() === 'cs' ? 'Žádná historie rezervací' : 'No booking history'}</p>
)}
</div>
</div>
</div>
<div class="p-6 border-t border-border">
<button
onClick={() => setShowCustomerDetail(false)}
class="w-full px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-colors"
>
{i18n.locale() === 'cs' ? 'Zavřít' : 'Close'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
// ==========================================
// ZONES & AVAILABILITY PAGE
// ==========================================
const ZonesPage = () => {
const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours">("zones");
const [showAddZone, setShowAddZone] = createSignal(false);
const [showAddBlocked, setShowAddBlocked] = createSignal(false);
const [newZoneName, setNewZoneName] = createSignal("");
const [newZoneType, setNewZoneType] = createSignal("room");
const [newZoneCapacity, setNewZoneCapacity] = createSignal(10);
const [newBlockedDate, setNewBlockedDate] = createSignal("");
const [newBlockedReason, setNewBlockedReason] = createSignal("");
const [newBlockedType, setNewBlockedType] = createSignal<"full" | "partial">("full");
// Fetch locations/zones
const [locations, { refetch: refetchLocations }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return demoData().zones;
const response = await (apiClient as any).GET("/v1/catalog/locations", {
headers: { Authorization: `Bearer ${bearer}` }
});
return response.data ?? [];
});
// Fetch blocked days
const [blockedDays, { refetch: refetchBlockedDays }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return demoData().blockedDays;
const response = await (apiClient as any).GET("/v1/catalog/blocked-days", {
headers: { Authorization: `Bearer ${bearer}` }
});
return response.data ?? [];
});
// Fetch working hours
const [workingHours, { refetch: refetchWorkingHours }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) return demoData().workingHours;
const response = await (apiClient as any).GET("/v1/catalog/working-hours", {
headers: { Authorization: `Bearer ${bearer}` }
});
return response.data ?? [];
});
const demoData = () => ({
zones: [
{ id: "1", name: "Main Studio", capacity: 15, type: "room", bookingsToday: 8 },
{ id: "2", name: "Treatment Room A", capacity: 2, type: "private", bookingsToday: 4 },
{ id: "3", name: "Treatment Room B", capacity: 2, type: "private", bookingsToday: 3 },
{ id: "4", name: "Group Hall", capacity: 30, type: "hall", bookingsToday: 12 },
],
blockedDays: [
{ id: "1", date: new Date(Date.now() + 604800000).toISOString(), reason: "Maintenance", type: "full" },
{ id: "2", date: new Date(Date.now() + 1209600000).toISOString(), reason: "Holiday", type: "full" },
{ id: "3", date: new Date(Date.now() + 259200000).toISOString(), reason: "Private Event", type: "partial" },
],
workingHours: [
{ dayOfWeek: 1, day: "Mon", open: "09:00", close: "18:00", isOpen: true },
{ dayOfWeek: 2, day: "Tue", open: "09:00", close: "18:00", isOpen: true },
{ dayOfWeek: 3, day: "Wed", open: "09:00", close: "18:00", isOpen: true },
{ dayOfWeek: 4, day: "Thu", open: "09:00", close: "18:00", isOpen: true },
{ dayOfWeek: 5, day: "Fri", open: "09:00", close: "17:00", isOpen: true },
{ dayOfWeek: 6, day: "Sat", open: "10:00", close: "14:00", isOpen: true },
{ dayOfWeek: 0, day: "Sun", open: "10:00", close: "14:00", isOpen: false },
],
});
const resolvedLocations = () => (locations.latest as any[]) ?? demoData().zones;
const resolvedBlockedDays = () => (blockedDays.latest as any[]) ?? demoData().blockedDays;
const resolvedWorkingHours = () => (workingHours.latest as any[]) ?? demoData().workingHours;
const handleAddZone = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) {
setShowAddZone(false);
return;
}
await (apiClient as any).POST("/v1/catalog/locations", {
headers: { Authorization: `Bearer ${bearer}` },
body: { name: newZoneName(), type: newZoneType(), capacity: newZoneCapacity() }
});
setNewZoneName("");
setNewZoneType("room");
setNewZoneCapacity(10);
setShowAddZone(false);
void refetchLocations();
};
const handleAddBlockedDay = async () => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) {
setShowAddBlocked(false);
return;
}
const date = new Date(newBlockedDate());
await (apiClient as any).POST("/v1/catalog/blocked-days", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
date: date.toISOString(),
reason: newBlockedReason(),
type: newBlockedType()
}
});
setNewBlockedDate("");
setNewBlockedReason("");
setNewBlockedType("full");
setShowAddBlocked(false);
void refetchBlockedDays();
};
const handleDeleteBlockedDay = async (id: string) => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) return;
await (apiClient as any).DELETE(`/v1/catalog/blocked-days/${id}` as string, {
headers: { Authorization: `Bearer ${bearer}` }
});
void refetchBlockedDays();
};
return (
<div class={`space-y-6 transition-opacity duration-200 ${isPageTransitioning() ? 'opacity-0' : 'opacity-100'}`}>
<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">{i18n.locale() === 'cs' ? 'Zony a dostupnost' : 'Zones & Availability'}</h1>
<p class="text-ink-muted mt-1">{i18n.locale() === 'cs' ? 'Spravujte prostory, blokované dny a pracovní dobu' : 'Manage spaces, blocked days and working hours'}</p>
</div>
</div>
{/* Tabs */}
<div class="flex gap-2 p-1 bg-canvas-subtle rounded-xl w-fit">
{[
{ id: "zones", label: i18n.locale() === 'cs' ? 'Zony' : 'Zones', icon: MapPinIcon },
{ id: "blocked", label: i18n.locale() === 'cs' ? 'Blokované dny' : 'Blocked Days', icon: BanIcon },
{ id: "hours", label: i18n.locale() === 'cs' ? 'Pracovní doba' : 'Working Hours', 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>
{/* Zones Tab */}
{activeTab() === "zones" && (
<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">{i18n.locale() === 'cs' ? 'Prostory a zony' : 'Spaces & Zones'}</h3>
<button
onClick={() => setShowAddZone(true)}
class="btn-primary text-sm"
>
<PlusIcon />
{i18n.locale() === 'cs' ? 'Přidat zonu' : 'Add Zone'}
</button>
</div>
{/* Add Zone Modal */}
<Show when={showAddZone()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{i18n.locale() === 'cs' ? 'Nová zóna' : 'New Zone'}</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input
type="text"
label={i18n.locale() === 'cs' ? 'Název' : 'Name'}
value={newZoneName()}
onInput={(e) => setNewZoneName(e.currentTarget.value)}
placeholder={i18n.locale() === 'cs' ? 'např. Hlavní sál' : 'e.g. Main Hall'}
/>
<Select
label={i18n.locale() === 'cs' ? 'Typ' : 'Type'}
value={newZoneType()}
onChange={(v) => setNewZoneType(v)}
options={[
{ value: "room", label: i18n.locale() === 'cs' ? 'Místnost' : 'Room' },
{ value: "private", label: i18n.locale() === 'cs' ? 'Soukromá místnost' : 'Private Room' },
{ value: "hall", label: i18n.locale() === 'cs' ? 'Sál' : 'Hall' },
]}
/>
<Input
type="number"
label={i18n.locale() === 'cs' ? 'Kapacita' : '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">
{i18n.locale() === 'cs' ? 'Uložit' : 'Save'}
</button>
<button onClick={() => setShowAddZone(false)} class="btn-secondary text-sm">
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</button>
</div>
</div>
</Show>
<div class="divide-y divide-border">
{resolvedLocations().map((zone: any, index: number) => (
<div
class="group flex items-center justify-between p-6 hover:bg-canvas-subtle/50 transition-colors"
style={{ "animation-delay": `${index * 50}ms` }}
>
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
<MapPinIcon />
</div>
<div>
<h4 class="font-medium text-ink">{zone.name}</h4>
<p class="text-sm text-ink-muted">
{zone.type === 'room' ? (i18n.locale() === 'cs' ? 'Místnost' : 'Room') :
zone.type === 'private' ? (i18n.locale() === 'cs' ? 'Soukromá místnost' : 'Private Room') :
i18n.locale() === 'cs' ? 'Sál' : 'Hall'}
{' • '}
{i18n.locale() === 'cs' ? 'Kapacita' : 'Capacity'}: {zone.capacity}
</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<p class="text-sm font-medium text-ink">{zone.bookingsToday}</p>
<p class="text-xs text-ink-muted">{i18n.locale() === 'cs' ? 'rezervací dnes' : 'bookings today'}</p>
</div>
<button class="p-2 text-ink-muted hover:text-ink hover:bg-canvas-subtle rounded-lg transition-colors opacity-0 group-hover:opacity-100">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Blocked Days Tab */}
{activeTab() === "blocked" && (
<div class="space-y-6">
<div class="surface-card p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-display font-semibold text-ink">{i18n.locale() === 'cs' ? 'Blokované dny' : 'Blocked Days'}</h3>
<p class="text-sm text-ink-muted mt-1">{i18n.locale() === 'cs' ? 'Zákazníci nebudou moci rezervovat v tyto dny' : 'Customers will not be able to book on these days'}</p>
</div>
<button
onClick={() => setShowAddBlocked(true)}
class="btn-primary text-sm"
>
<PlusIcon />
{i18n.locale() === 'cs' ? 'Blokovat den' : 'Block Day'}
</button>
</div>
<div class="space-y-3">
{resolvedBlockedDays().map((blocked: any, index: number) => (
<div
class="flex items-center justify-between p-4 bg-canvas-subtle rounded-xl border border-border"
style={{ "animation-delay": `${index * 50}ms` }}
>
<div class="flex items-center gap-4">
<div class={`w-10 h-10 rounded-lg flex items-center justify-center ${
blocked.type === 'full' ? 'bg-[hsl(var(--error-subtle))] text-[hsl(var(--error))]' : 'bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))]'
}`}>
{blocked.type === 'full' ? <BanIcon /> : <ClockOffIcon />}
</div>
<div>
<p class="font-medium text-ink">{new Date(blocked.date).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>
<p class="text-sm text-ink-muted">{blocked.reason}</p>
</div>
</div>
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
blocked.type === 'full' ? 'bg-[hsl(var(--error-soft))] text-[hsl(var(--error))]' : 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]'
}`}>
{blocked.type === 'full'
? (i18n.locale() === 'cs' ? 'Celý den' : 'Full Day')
: (i18n.locale() === 'cs' ? 'Částečný' : 'Partial')
}
</span>
</div>
))}
</div>
{/* Add Blocked Day Form */}
<Show when={showAddBlocked()}>
<div class="mt-6 p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-medium text-ink mb-4">{i18n.locale() === 'cs' ? 'Blokovat nový den' : 'Block New Day'}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<Input
type="date"
label={i18n.locale() === 'cs' ? 'Datum' : 'Date'}
value={newBlockedDate()}
onInput={(e) => setNewBlockedDate(e.currentTarget.value)}
/>
<Select
label={i18n.locale() === 'cs' ? 'Typ' : 'Type'}
value={newBlockedType()}
onChange={(v) => setNewBlockedType(v as "full" | "partial")}
options={[
{ value: "full", label: i18n.locale() === 'cs' ? 'Celý den' : 'Full Day' },
{ value: "partial", label: i18n.locale() === 'cs' ? 'Částečný' : 'Partial' },
]}
/>
</div>
<div class="mb-4">
<Input
type="text"
label={i18n.locale() === 'cs' ? 'Důvod' : 'Reason'}
value={newBlockedReason()}
onInput={(e) => setNewBlockedReason(e.currentTarget.value)}
placeholder={i18n.locale() === 'cs' ? 'např. Dovolená, Údržba...' : 'e.g. Holiday, Maintenance...'}
/>
</div>
<div class="flex gap-3">
<button
onClick={handleAddBlockedDay}
disabled={!newBlockedDate() || !newBlockedReason()}
class="px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{i18n.locale() === 'cs' ? 'Blokovat' : 'Block'}
</button>
<button
onClick={() => {
setShowAddBlocked(false);
setNewBlockedDate("");
setNewBlockedReason("");
setNewBlockedType("full");
}}
class="px-4 py-2.5 border border-border rounded-xl text-ink hover:bg-canvas transition-colors text-sm"
>
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</button>
</div>
</div>
</Show>
</div>
{/* Quick Block */}
<div class="surface-card p-6">
<h3 class="text-lg font-display font-semibold text-ink mb-4">{i18n.locale() === 'cs' ? 'Rychlé blokování' : 'Quick Block'}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button class="p-4 bg-canvas-subtle rounded-xl border border-border hover:border-[hsl(var(--error))]/30 hover:bg-[hsl(var(--error-soft))]/50 transition-all text-left">
<div class="w-8 h-8 rounded-lg bg-[hsl(var(--error-subtle))] flex items-center justify-center text-[hsl(var(--error))] mb-3">
<BanIcon />
</div>
<p class="font-medium text-ink text-sm">{i18n.locale() === 'cs' ? 'Víkendy' : 'Weekends'}</p>
<p class="text-xs text-ink-muted">{i18n.locale() === 'cs' ? 'Blokovat všechny víkendy' : 'Block all weekends'}</p>
</button>
<button class="p-4 bg-canvas-subtle rounded-xl border border-border hover:border-[hsl(var(--warning))]/30 hover:bg-[hsl(var(--warning-soft))]/50 transition-all text-left">
<div class="w-8 h-8 rounded-lg bg-[hsl(var(--warning-subtle))] flex items-center justify-center text-[hsl(var(--warning))] mb-3">
<ClockOffIcon />
</div>
<p class="font-medium text-ink text-sm">{i18n.locale() === 'cs' ? 'Státní svátky' : 'Public Holidays'}</p>
<p class="text-xs text-ink-muted">{i18n.locale() === 'cs' ? 'Importovat oficiální svátky' : 'Import official holidays'}</p>
</button>
<button class="p-4 bg-canvas-subtle rounded-xl border border-border hover:border-accent/30 hover:bg-accent-subtle/50 transition-all text-left">
<div class="w-8 h-8 rounded-lg bg-accent-subtle flex items-center justify-center text-accent mb-3">
<CalendarDaysIcon />
</div>
<p class="font-medium text-ink text-sm">{i18n.locale() === 'cs' ? 'Rekurentní' : 'Recurring'}</p>
<p class="text-xs text-ink-muted">{i18n.locale() === 'cs' ? 'Nastavit pravidelné volno' : 'Set regular time off'}</p>
</button>
</div>
</div>
</div>
)}
{/* Working Hours Tab */}
{activeTab() === "hours" && (
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{i18n.locale() === 'cs' ? 'Pracovní doba' : 'Working Hours'}</h3>
<p class="text-sm text-ink-muted mt-1">{i18n.locale() === 'cs' ? 'Nastavte kdy jste otevření pro rezervace' : 'Set when you are open for bookings'}</p>
</div>
<div class="divide-y divide-border">
{resolvedWorkingHours().map((hours: any, index: number) => (
<div
class="flex items-center justify-between p-6 hover:bg-canvas-subtle/50 transition-colors"
style={{ "animation-delay": `${index * 50}ms` }}
>
<div class="flex items-center gap-4">
<div class={`w-12 h-12 rounded-xl flex items-center justify-center ${
hours.isOpen ? 'bg-[hsl(var(--success-subtle))] text-[hsl(var(--success))]' : 'bg-canvas-muted text-ink-muted'
}`}>
<span class="text-lg font-display font-bold">{hours.day}</span>
</div>
<div>
<p class="font-medium text-ink">
{hours.day === 'Mon' ? (i18n.locale() === 'cs' ? 'Pondělí' : 'Monday') :
hours.day === 'Tue' ? (i18n.locale() === 'cs' ? 'Úterý' : 'Tuesday') :
hours.day === 'Wed' ? (i18n.locale() === 'cs' ? 'Středa' : 'Wednesday') :
hours.day === 'Thu' ? (i18n.locale() === 'cs' ? 'Čtvrtek' : 'Thursday') :
hours.day === 'Fri' ? (i18n.locale() === 'cs' ? 'Pátek' : 'Friday') :
hours.day === 'Sat' ? (i18n.locale() === 'cs' ? 'Sobota' : 'Saturday') :
i18n.locale() === 'cs' ? 'Neděle' : 'Sunday'}
</p>
<p class="text-sm text-ink-muted">
{hours.isOpen ? `${hours.open} - ${hours.close}` : (i18n.locale() === 'cs' ? 'Zavřeno' : 'Closed')}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={hours.isOpen}
class="sr-only peer"
readOnly
/>
<div class="w-11 h-6 bg-canvas-muted peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-accent/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-accent"></div>
</label>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
// ==========================================
// RENDER
// ==========================================
const ActivePage = () => {
switch (activeSection()) {
case "overview": return <OverviewPage />;
case "bookings": return <BookingsPage />;
case "customers": return <CustomersPage />;
case "zones": return <ZonesPage />;
case "billing": return <BillingPage />;
case "settings": return <SettingsPage />;
default: return <OverviewPage />;
}
};
return (
<div class="min-h-screen flex bg-gradient-to-br from-canvas-subtle via-canvas to-canvas-subtle">
<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">
<img src="/bookra-logo.svg" alt="Bookra" class="h-8 w-auto dark:invert" />
<button
onClick={() => setIsMobileMenuOpen(true)}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
>
<MenuIcon />
</button>
</div>
<MobileMenu />
<main class="flex-1 p-4 lg:p-8 overflow-y-auto font-dashboard">
{/* Access Denied State */}
<Show when={!token.loading && !token()}>
<div class="max-w-md mx-auto py-20 text-center">
<div class="relative inline-block">
<div class="absolute inset-0 bg-[hsl(var(--error-soft))] rounded-full blur-3xl opacity-50" />
<BookraCharacter pose="forbidden" size="xl" animate={true} />
</div>
<h3 class="text-2xl font-bold text-ink mt-8">{i18n.locale() === 'cs' ? 'Přístup odepřen' : 'Access Denied'}</h3>
<p class="text-ink-muted mt-2 max-w-xs mx-auto">{i18n.locale() === 'cs' ? 'Pro přístup do dashboardu se musíte přihlásit.' : 'Please sign in to access the dashboard.'}</p>
</div>
</Show>
{/* Loading State */}
<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' ? 'Načítání dashboardu...' : 'Loading your dashboard...'}</p>
</div>
</Show>
{/* Dashboard Content */}
<Show when={isDashboardReady()}>
<div class="max-w-7xl mx-auto">
<ActivePage />
</div>
</Show>
</main>
</div>
{/* Integration Modal */}
<IntegrationModal
isOpen={showIntegrationModal()}
onClose={() => setShowIntegrationModal(false)}
tenantSlug={resolvedSummary()?.tenantSlug || "demo-studio"}
publicBookingUrl={resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio"}
tenantName={resolvedSummary()?.tenantName || "Demo Studio"}
primaryColor={resolvedBootstrap()?.brand?.primaryColor}
/>
</div>
);
}