This commit is contained in:
Tomas Dvorak
2026-05-05 09:48:07 +02:00
parent d854614a87
commit 48c3e15a38
295 changed files with 178381 additions and 1039 deletions
+31 -15
View File
@@ -1,21 +1,37 @@
import { Route } from "@solidjs/router";
import type { ParentComponent } from "solid-js";
import { createEffect, onMount } from "solid-js";
import { useLocation } from "@solidjs/router";
import { AuthProvider } from "./providers/auth-provider";
import { I18nProvider } from "./providers/i18n-provider";
import { ThemeProvider } from "./providers/theme-provider";
import { Shell } from "./components/shell";
import { DashboardRoute } from "./routes/dashboard-route";
import { HomeRoute } from "./routes/home-route";
import { PublicBookingRoute } from "./routes/public-booking-route";
export default function App() {
// ScrollToTop component that resets scroll position on route change
const ScrollToTop = () => {
const location = useLocation();
createEffect(() => {
// Reset scroll position whenever the pathname changes
location.pathname;
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
});
return null;
};
const App: ParentComponent = (props) => {
return (
<I18nProvider>
<AuthProvider>
<Shell>
<Route path="/" component={HomeRoute} />
<Route path="/dashboard" component={DashboardRoute} />
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
</Shell>
</AuthProvider>
</I18nProvider>
<ThemeProvider>
<I18nProvider>
<AuthProvider>
<Shell>
<ScrollToTop />
{props.children}
</Shell>
</AuthProvider>
</I18nProvider>
</ThemeProvider>
);
}
};
export default App;
@@ -0,0 +1,240 @@
import { JSX, createSignal, onMount, Show } from "solid-js";
export type CharacterPose =
| "main"
| "hello"
| "laptop"
| "sleep"
| "lookup"
| "success"
| "like"
| "walk"
| "announcement"
| "educate"
| "happy_note"
| "maintenance"
| "sad"
// New mascot poses
| "time"
| "flag"
| "error"
| "headphones"
| "checkbox"
| "coffee"
| "coffe"
| "angry_bulb"
| "anoyed_lamp"
// Grayscaled error states
| "404"
| "forbidden"
| "connection_error"
| "fixing";
interface BookraCharacterProps {
pose: CharacterPose;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
animate?: boolean;
class?: string;
alt?: string;
}
const sizeMap = {
xs: "w-16 h-16",
sm: "w-24 h-24",
md: "w-32 h-32",
lg: "w-48 h-48",
xl: "w-64 h-64",
"2xl": "w-80 h-80",
};
const illustrationMap: Record<CharacterPose, string> = {
main: "/bookra-illustrations/main.svg",
hello: "/bookra-illustrations/hello.svg",
laptop: "/bookra-illustrations/laptop.svg",
sleep: "/bookra-illustrations/sleep.svg",
lookup: "/bookra-illustrations/lookup.svg",
success: "/bookra-illustrations/success.svg",
like: "/bookra-illustrations/like.svg",
walk: "/bookra-illustrations/walk.svg",
announcement: "/bookra-illustrations/announcement.svg",
educate: "/bookra-illustrations/educate.svg",
happy_note: "/bookra-illustrations/happy-note.svg",
maintenance: "/bookra-illustrations/maintenance.svg",
sad: "/bookra-illustrations/sad.svg",
time: "/bookra-illustrations/clock.svg",
flag: "/bookra-illustrations/flag.svg",
error: "/bookra-illustrations/sad.svg",
headphones: "/bookra-illustrations/headphones.svg",
checkbox: "/bookra-illustrations/happy-note.svg",
coffee: "/bookra-illustrations/coffee.svg",
coffe: "/bookra-illustrations/coffee.svg",
angry_bulb: "/bookra-illustrations/angry-bulb.svg",
anoyed_lamp: "/bookra-illustrations/angry-bulb.svg",
"404": "/bookra-illustrations/404.svg",
forbidden: "/bookra-illustrations/forbidden.svg",
connection_error: "/bookra-illustrations/connection-error.svg",
fixing: "/bookra-illustrations/maintenance.svg",
};
export function BookraCharacter(props: BookraCharacterProps): JSX.Element {
const [isLoaded, setIsLoaded] = createSignal(false);
const [hasError, setHasError] = createSignal(false);
const sizeClass = () => sizeMap[props.size || "md"];
const animationClass = () => props.animate !== false ? "animate-float" : "";
const characterPath = () => illustrationMap[props.pose];
const label = () => props.alt || `Bookra illustration - ${props.pose.replaceAll("_", " ")}`;
return (
<div
class={`relative inline-flex items-center justify-center ${sizeClass()} ${props.class || ""}`}
aria-label={label()}
>
<Show when={!hasError()}>
<img
src={characterPath()}
alt={label()}
class={`w-full h-full object-contain transition-all duration-500 ${
isLoaded() ? "opacity-100 scale-100" : "opacity-0 scale-95"
} ${animationClass()}`}
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
/>
</Show>
<Show when={hasError()}>
<div class="w-full h-full rounded-2xl bg-accent/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
</Show>
</div>
);
}
// Character with speech bubble for contextual messages
interface CharacterMessageProps extends BookraCharacterProps {
message: string;
position?: "left" | "right" | "top" | "bottom";
}
export function CharacterWithMessage(props: CharacterMessageProps): JSX.Element {
const positionClass = () => {
switch (props.position || "right") {
case "left": return "flex-row-reverse";
case "right": return "flex-row";
case "top": return "flex-col-reverse";
case "bottom": return "flex-col";
}
};
const bubblePosition = () => {
switch (props.position || "right") {
case "left": return "mr-4";
case "right": return "ml-4";
case "top": return "mb-3";
case "bottom": return "mt-3";
}
};
return (
<div class={`flex items-center ${positionClass()}`}>
<BookraCharacter
pose={props.pose}
size={props.size}
animate={props.animate}
class={props.class}
alt={props.alt}
/>
<div class={`surface-elevated px-4 py-3 rounded-2xl rounded-${
props.position === "left" ? "r" :
props.position === "right" ? "l" :
props.position === "top" ? "b" : "t"
}-lg max-w-xs ${bubblePosition()}`}>
<p class="text-ink text-sm leading-relaxed">{props.message}</p>
</div>
</div>
);
}
// Empty state with character
interface CharacterEmptyStateProps {
pose: CharacterPose;
title: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
size?: "sm" | "md" | "lg";
}
export function CharacterEmptyState(props: CharacterEmptyStateProps): JSX.Element {
return (
<div class="flex flex-col items-center text-center py-12 px-4">
<BookraCharacter
pose={props.pose}
size={props.size || "lg"}
animate={true}
class="mb-6"
/>
<h3 class="font-display text-xl font-semibold text-ink mb-2">
{props.title}
</h3>
<p class="text-ink-muted max-w-sm mb-6 leading-relaxed">
{props.message}
</p>
{props.action && (
<button
onClick={props.action.onClick}
class="btn-primary"
>
{props.action.label}
</button>
)}
</div>
);
}
// Success state with character celebration
interface CharacterSuccessStateProps {
title: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
export function CharacterSuccessState(props: CharacterSuccessStateProps): JSX.Element {
return (
<div class="flex flex-col items-center text-center py-12 px-4 animate-fade-in">
<div class="relative mb-6">
<BookraCharacter
pose="success"
size="xl"
animate={true}
/>
<div class="absolute -top-2 -right-2 text-3xl animate-bounce"></div>
</div>
<h3 class="font-display text-2xl font-semibold text-ink mb-3">
{props.title}
</h3>
<p class="text-ink-muted max-w-md mb-8 leading-relaxed">
{props.message}
</p>
{props.action && (
<button
onClick={props.action.onClick}
class="btn-primary inline-flex items-center gap-2"
>
{props.action.label}
</button>
)}
</div>
);
}
@@ -0,0 +1,178 @@
export 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-accent-subtle"/>
<rect width="7" height="5" x="14" y="3" rx="1" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
<rect width="7" height="9" x="14" y="12" rx="1" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
<rect width="7" height="5" x="3" y="16" rx="1" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
</svg>
);
export 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-accent-subtle"/>
<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>
);
export 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-accent-subtle"/>
<line x1="2" x2="22" y1="10" y2="10"/>
</svg>
);
export 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-accent-subtle"/>
<circle cx="7" cy="7" r="3" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
</svg>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
export 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>
);
@@ -0,0 +1,28 @@
import type { JSX } from "solid-js";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
export type BookingStatus = "confirmed" | "pending" | "cancelled";
export interface KpiData {
label: string;
value: number | string;
change?: string;
trend?: "up" | "down" | "neutral";
icon: () => JSX.Element;
}
export const getInitials = (name?: string) =>
(name ?? "User")
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("") || "U";
export 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`;
};
+4
View File
@@ -0,0 +1,4 @@
export { BookraCharacter } from "./bookra-character";
export { LocationMap } from "./location-map";
export { WidgetBuilder } from "./widget-builder";
export { IntegrationModal } from "./integration-modal";
@@ -0,0 +1,213 @@
import { createSignal, Show, For, type JSX } from "solid-js";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Button, Tabs, TabsList, TabsTrigger, TabsContent, DialogCloseButton } from "./ui";
interface IntegrationModalProps {
isOpen: boolean;
onClose: () => void;
tenantSlug: string;
publicBookingUrl: string;
tenantName: string;
primaryColor?: string;
}
export function IntegrationModal(props: IntegrationModalProps) {
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
const copyToClipboard = async (text: string, type: string) => {
await navigator.clipboard.writeText(text);
setCopiedSnippet(type);
setTimeout(() => setCopiedSnippet(null), 2000);
};
const hostedPageUrl = `https://bookra.eu/book/${props.tenantSlug}`;
const htmlWidgetCode = `<div id="bookra-widget"></div>
<script>
(function() {
var script = document.createElement('script');
script.src = "https://bookra.eu/widget.js";
script.async = true;
script.onload = function() {
BookraWidget.init({
tenantSlug: "${props.tenantSlug}",
container: "#bookra-widget",
theme: "light"
});
};
document.head.appendChild(script);
})();
</script>`;
const reactWidgetCode = `import { BookraWidget } from '@bookra/react-widget';
function App() {
return (
<BookraWidget
tenantSlug="${props.tenantSlug}"
theme="light"
/>
);
}`;
const solidWidgetCode = `import { BookraWidget } from '@bookra/solid-widget';
function App() {
return (
<BookraWidget
tenantSlug="${props.tenantSlug}"
theme="light"
/>
);
}`;
const phpWidgetCode = `<?php
// Add to your WordPress functions.php or PHP template
add_action('wp_footer', function() {
?>
<div id="bookra-widget"></div>
<script src="https://bookra.eu/widget.js" async></script>
<script>
window.addEventListener('load', function() {
BookraWidget.init({
tenantSlug: "${props.tenantSlug}",
container: "#bookra-widget",
theme: "light"
});
});
</script>
<?php
});`;
return (
<Dialog open={props.isOpen} onClose={props.onClose}>
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="text-2xl font-display">
Add Bookra to Your Website
</DialogTitle>
<DialogDescription>
Choose how you want to integrate Bookra with your business. Share a link or embed directly on your website.
</DialogDescription>
</DialogHeader>
<DialogCloseButton onClose={props.onClose} />
<Tabs defaultValue="hosted" class="mt-6">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="hosted">Hosted Page</TabsTrigger>
<TabsTrigger value="embed">Embed Widget</TabsTrigger>
</TabsList>
<TabsContent value="hosted" class="space-y-6 mt-6">
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-display font-semibold text-ink mb-3">Your Booking Page</h4>
<p class="text-sm text-ink-muted mb-4">
Share this link with your customers. They can book directly without any setup on your website.
</p>
<div class="flex items-center gap-3 p-4 bg-canvas rounded-xl border border-border">
<code class="flex-1 text-sm text-ink truncate font-mono">{hostedPageUrl}</code>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(hostedPageUrl, "url")}
>
{copiedSnippet() === "url" ? "Copied!" : "Copy"}
</Button>
</div>
<div class="mt-4 flex items-center gap-2 text-sm text-ink-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="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>
<span>Perfect for social media, email signatures, or direct sharing</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(hostedPageUrl)}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 p-3 rounded-xl bg-[hsl(220,70%,55%)]/10 text-[hsl(220,70%,55%)] hover:bg-[hsl(220,70%,55%)]/20 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Share on Facebook
</a>
<a
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(`Book your appointment with ${props.tenantName}!`)}&url=${encodeURIComponent(hostedPageUrl)}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 p-3 rounded-xl bg-[hsl(200,15%,10%)]/10 text-ink hover:bg-[hsl(200,15%,10%)]/20 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Share on X
</a>
</div>
</TabsContent>
<TabsContent value="embed" class="space-y-6 mt-6">
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-display font-semibold text-ink mb-3">Embed on Your Website</h4>
<p class="text-sm text-ink-muted mb-4">
Add the booking widget directly to your website. Choose your platform:
</p>
<Tabs defaultValue="html" class="w-full">
<TabsList class="grid w-full grid-cols-4 mb-4">
<TabsTrigger value="html">HTML/JS</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="solid">SolidJS</TabsTrigger>
<TabsTrigger value="php">PHP/WordPress</TabsTrigger>
</TabsList>
<For each={[
{ id: "html", label: "HTML/JavaScript", code: htmlWidgetCode },
{ id: "react", label: "React", code: reactWidgetCode },
{ id: "solid", label: "SolidJS", code: solidWidgetCode },
{ id: "php", label: "PHP/WordPress", code: phpWidgetCode },
]}>
{(item) => (
<TabsContent value={item.id} class="mt-0">
<div class="relative">
<pre class="p-4 bg-ink text-canvas rounded-xl overflow-x-auto text-sm font-mono"><code>{item.code}</code></pre>
<Button
variant="secondary"
size="sm"
class="absolute top-3 right-3"
onClick={() => copyToClipboard(item.code, item.id)}
>
{copiedSnippet() === item.id ? "Copied!" : "Copy"}
</Button>
</div>
</TabsContent>
)}
</For>
</Tabs>
<div class="mt-4 p-4 bg-[hsl(var(--info-subtle))] rounded-xl border border-[hsl(var(--info))/20]">
<div class="flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-[hsl(var(--info))] mt-0.5 shrink-0">
<circle cx="12" cy="12" r="10"/>
<line x1="12" x2="12" y1="16" y2="12"/>
<line x1="12" x2="12.01" y1="8" y2="8"/>
</svg>
<div>
<p class="text-sm font-medium text-ink">Need help with installation?</p>
<p class="text-sm text-ink-muted mt-1">
Contact our support team at <a href="mailto:support@bookra.eu" class="text-accent hover:underline">support@bookra.eu</a> for assistance with embedding the widget.
</p>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,213 @@
import { Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import {
DEFAULT_MAP_STYLE_ID,
resolveMapTileStyle,
validateCoordinates,
type MapCoordinates,
} from "../lib/map";
declare global {
interface Window {
L?: any;
}
}
interface LocationMapProps {
coordinates: MapCoordinates;
mapStyle?: string;
customTileUrl?: string;
markerColor?: string;
markerLabel?: string;
height?: number;
zoom?: number;
interactive?: boolean;
class?: string;
}
let leafletPromise: Promise<any> | null = null;
function loadLeaflet(): Promise<any> {
if (window.L) return Promise.resolve(window.L);
if (leafletPromise) return leafletPromise;
leafletPromise = new Promise((resolve, reject) => {
if (!document.getElementById("leaflet-css")) {
const link = document.createElement("link");
link.id = "leaflet-css";
link.rel = "stylesheet";
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(link);
}
const existingScript = document.getElementById("leaflet-js") as HTMLScriptElement | null;
if (existingScript) {
existingScript.addEventListener("load", () => resolve(window.L));
existingScript.addEventListener("error", () => reject(new Error("Map library failed to load")));
return;
}
const script = document.createElement("script");
script.id = "leaflet-js";
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
script.async = true;
script.onload = () => resolve(window.L);
script.onerror = () => reject(new Error("Map library failed to load"));
document.head.appendChild(script);
});
return leafletPromise;
}
function safeMarkerColor(color: string | undefined): string {
const fallback = "#a65c3e";
if (!color) return fallback;
const trimmed = color.trim();
return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : fallback;
}
function escapeHtml(value: string | undefined): string {
return (value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function createMarkerIcon(leaflet: any, color: string | undefined) {
const safeColor = safeMarkerColor(color);
return leaflet.divIcon({
className: "bookra-map-marker",
html: `<span style="--bookra-marker-color:${safeColor}"></span>`,
iconSize: [34, 42],
iconAnchor: [17, 40],
popupAnchor: [0, -38],
});
}
export function LocationMap(props: LocationMapProps) {
let mapElement: HTMLDivElement | undefined;
let map: any;
let tileLayer: any;
let marker: any;
let leaflet: any;
const [isReady, setIsReady] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const zoom = () => props.zoom ?? props.coordinates.zoom ?? 15;
const mapStyle = () => resolveMapTileStyle(props.mapStyle ?? DEFAULT_MAP_STYLE_ID, props.customTileUrl);
const popupContent = () => {
const label = props.markerLabel ?? props.coordinates.address;
const address = props.coordinates.address && props.coordinates.address !== label ? props.coordinates.address : "";
if (!label && !address) return "";
return [
label ? `<strong>${escapeHtml(label)}</strong>` : "",
address ? `<span>${escapeHtml(address)}</span>` : "",
].filter(Boolean).join("<br>");
};
onMount(() => {
loadLeaflet()
.then((loadedLeaflet) => {
leaflet = loadedLeaflet;
setIsReady(true);
})
.catch((loadError: Error) => {
setError(loadError.message);
});
});
createEffect(() => {
if (!isReady() || !leaflet || !mapElement || map) return;
const { latitude, longitude } = props.coordinates;
if (!validateCoordinates(latitude, longitude)) {
setError("Invalid map coordinates");
return;
}
map = leaflet.map(mapElement, {
center: [latitude, longitude],
zoom: zoom(),
scrollWheelZoom: false,
dragging: props.interactive !== false,
touchZoom: props.interactive !== false,
doubleClickZoom: props.interactive !== false,
zoomControl: props.interactive !== false,
});
const style = mapStyle();
tileLayer = leaflet.tileLayer(style.url, {
attribution: style.attribution,
className: style.tileClassName ?? "",
maxZoom: 19,
}).addTo(map);
marker = leaflet
.marker([latitude, longitude], {
icon: createMarkerIcon(leaflet, props.markerColor),
})
.addTo(map);
const popup = popupContent();
if (popup) marker.bindPopup(popup);
setTimeout(() => map?.invalidateSize(), 120);
});
createEffect(() => {
if (!map || !marker) return;
const { latitude, longitude } = props.coordinates;
if (!validateCoordinates(latitude, longitude)) return;
map.setView([latitude, longitude], zoom());
marker.setLatLng([latitude, longitude]);
marker.setIcon(createMarkerIcon(leaflet, props.markerColor));
const popup = popupContent();
if (popup) {
marker.bindPopup(popup);
} else {
marker.unbindPopup();
}
});
createEffect(() => {
if (!map || !leaflet) return;
const style = mapStyle();
if (tileLayer) map.removeLayer(tileLayer);
tileLayer = leaflet.tileLayer(style.url, {
attribution: style.attribution,
className: style.tileClassName ?? "",
maxZoom: 19,
}).addTo(map);
});
onCleanup(() => {
if (map) {
map.remove();
map = null;
}
});
return (
<div
class={`bookra-location-map relative overflow-hidden rounded-card border border-border bg-canvas-subtle ${props.class ?? ""}`}
style={{ height: `${props.height ?? 360}px` }}
role="img"
aria-label={props.coordinates.address ? `Map for ${props.coordinates.address}` : "Location map"}
>
<div ref={mapElement} class="absolute inset-0" />
<Show when={!isReady() && !error()}>
<div class="absolute inset-0 grid place-items-center bg-canvas-subtle text-sm text-ink-muted">
Loading map...
</div>
</Show>
<Show when={error()}>
<div class="absolute inset-0 grid place-items-center bg-error-subtle px-6 text-center text-sm text-error">
{error()}
</div>
</Show>
</div>
);
}
+569 -35
View File
@@ -1,55 +1,589 @@
import { A } from "@solidjs/router";
import { ParentComponent, Show } from "solid-js";
import { A, useLocation, useNavigate } from "@solidjs/router";
import { ParentComponent, Show, createSignal, onMount, onCleanup } from "solid-js";
import { useAuth } from "../providers/auth-provider";
import { useI18n } from "../providers/i18n-provider";
import { useTheme } from "../providers/theme-provider";
import { BookraCharacter } from "./bookra-character";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "./ui";
// Icon components - refined with consistent sizing
const SunIcon = () => (
<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="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="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 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
);
const GlobeIcon = () => (
<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">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
const MenuIcon = () => (
<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">
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
);
const XIcon = () => (
<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="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
const GoogleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#EA4335" d="M12 10.2v3.9h5.4c-.2 1.3-1.5 3.9-5.4 3.9-3.2 0-5.9-2.7-5.9-6s2.7-6 5.9-6c1.8 0 3 .8 3.7 1.5l2.5-2.4C16.7 3.6 14.6 2.7 12 2.7 6.9 2.7 2.8 6.8 2.8 12S6.9 21.3 12 21.3c6.9 0 8.6-6.4 8.6-9.6 0-.6-.1-1-.2-1.5H12Z" />
<path fill="#34A853" d="M3.9 7 7 9.2c.8-2.2 2.8-3.8 5-3.8 1.8 0 3 .8 3.7 1.5l2.5-2.4C16.7 3.6 14.6 2.7 12 2.7 8.2 2.7 4.9 4.9 3.9 7Z" />
<path fill="#4A90E2" d="M12 21.3c2.5 0 4.6-.8 6.1-2.2l-2.8-2.3c-.8.6-1.8 1-3.3 1-3.8 0-4.9-2.5-5.2-3.7l-3 2.3c1 2.1 4.2 4.9 8.2 4.9Z" />
<path fill="#FBBC05" d="M6.8 14.1a6.4 6.4 0 0 1 0-4.1L3.9 7a9.4 9.4 0 0 0 0 10l2.9-2.9Z" />
</svg>
);
export const Shell: ParentComponent = (props) => {
const auth = useAuth();
const i18n = useI18n();
const theme = useTheme();
const location = useLocation();
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
const hideHeader = () => location.pathname.startsWith("/dashboard");
const [signInOpen, setSignInOpen] = createSignal(false);
const [authMode, setAuthMode] = createSignal<"sign-in" | "register">("sign-in");
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [confirmPassword, setConfirmPassword] = createSignal("");
const [authError, setAuthError] = createSignal<string | null>(null);
const [authNotice, setAuthNotice] = createSignal<string | null>(null);
const [authSubmitting, setAuthSubmitting] = createSignal(false);
const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in";
const navLinks = [
{ href: "/about", label: i18n.t("nav.about") },
{ href: "/contact", label: i18n.t("nav.contact") },
];
// Demo mode detection
const isDemoMode = () => {
const hostname = window.location.hostname;
return hostname.includes("demo") || hostname === "localhost" || hostname === "127.0.0.1";
};
// Listen for custom event to open auth dialog from dashboard
onMount(() => {
const handleOpenAuth = (e: Event) => {
const customEvent = e as CustomEvent;
setAuthMode(customEvent.detail?.mode || "sign-in");
setSignInOpen(true);
};
window.addEventListener("openAuthDialog", handleOpenAuth);
onCleanup(() => {
window.removeEventListener("openAuthDialog", handleOpenAuth);
});
});
const submitSignIn = async () => {
setAuthSubmitting(true);
setAuthError(null);
setAuthNotice(null);
try {
if (authMode() === "register") {
if (password().length < 8) {
throw new Error(i18n.t("auth.passwordTooShort"));
}
if (password() !== confirmPassword()) {
throw new Error(i18n.t("auth.passwordMismatch"));
}
await auth.signUpWithEmail(name().trim(), email().trim(), password());
} else {
await auth.signInWithEmail(email().trim(), password());
}
setSignInOpen(false);
setName("");
setEmail("");
setPassword("");
setConfirmPassword("");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Translate common validation errors
const translatedError = translateAuthError(errorMessage, i18n.locale());
setAuthError(translatedError);
} finally {
setAuthSubmitting(false);
}
};
// Helper to translate validation error messages
const translateAuthError = (error: string, locale: string): string => {
// Email required errors
if (error.includes("'Email'")) {
if (locale === 'cs') {
return 'E-mail je povinný';
}
return 'Email is required';
}
// Password required errors
if (error.includes("'Password'")) {
if (locale === 'cs') {
return 'Heslo je povinné';
}
return 'Password is required';
}
// Name required errors (for registration)
if (error.includes("'Name'")) {
if (locale === 'cs') {
return 'Jméno je povinné';
}
return 'Name is required';
}
// Invalid credentials
if (error.toLowerCase().includes('invalid') || error.toLowerCase().includes('credentials')) {
if (locale === 'cs') {
return 'Neplatné přihlašovací údaje';
}
return 'Invalid credentials';
}
// Return original if no translation
return error;
};
const sendMagicLink = async () => {
setAuthSubmitting(true);
setAuthError(null);
setAuthNotice(null);
try {
await auth.sendMagicLink(email().trim());
setAuthNotice(i18n.t("auth.magicLinkSent"));
setPassword("");
} catch (error) {
setAuthError(error instanceof Error ? error.message : i18n.t("auth.signInFailed"));
} finally {
setAuthSubmitting(false);
}
};
const signInWithGoogle = async () => {
setAuthSubmitting(true);
setAuthError(null);
setAuthNotice(null);
try {
await auth.signInWithGoogle();
} catch (error) {
setAuthError(error instanceof Error ? error.message : i18n.t("auth.signInFailed"));
setAuthSubmitting(false);
}
};
return (
<div class="min-h-screen">
<header class="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
<A class="text-lg font-semibold tracking-tight" href="/">
Bookra
</A>
<nav class="flex items-center gap-3 text-sm text-slate">
<A class="rounded-full border border-black/10 px-4 py-2 hover:border-ember" href="/dashboard">
{i18n.t("nav.dashboard")}
</A>
<A class="rounded-full border border-black/10 px-4 py-2 hover:border-ember" href="/book/studio-atelier">
{i18n.t("nav.booking")}
</A>
<button
class="rounded-full border border-black/10 px-4 py-2 hover:border-pine"
onClick={() => i18n.toggleLocale()}
type="button"
>
{i18n.locale().toUpperCase()}
</button>
<Show
when={auth.session()}
fallback={
<div class="min-h-screen flex flex-col">
<Show when={!hideHeader()}>
<header class="sticky top-0 z-50 w-full border-b border-border/60 bg-canvas/75 backdrop-blur-xl">
<div class="section-container">
<div class="flex h-16 items-center justify-between">
{/* Logo */}
<A
class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-all duration-300 group"
href="/"
onClick={() => window.scrollTo(0, 0)}
>
<img
src="/bookra-logo.svg"
alt="Bookra"
class="h-8 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
/>
</A>
{/* Desktop Navigation */}
<nav class="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<A
class="relative px-4 py-2 text-sm font-display font-medium text-ink-muted hover:text-ink transition-all duration-300 rounded-lg hover:bg-canvas-subtle group"
href={link.href}
activeClass="text-ink bg-canvas-subtle"
onClick={() => window.scrollTo(0, 0)}
>
{link.label}
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-0 h-0.5 bg-accent rounded-full transition-all duration-300 group-hover:w-4" />
</A>
))}
<div class="h-5 w-px bg-border mx-1" />
{/* Theme Toggle */}
<button
class="rounded-full bg-ink px-4 py-2 text-canvas"
onClick={() => void auth.signInDemo()}
class="p-2.5 rounded-lg text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-300 group relative overflow-hidden"
onClick={() => theme.toggle()}
type="button"
aria-label={theme.resolvedTheme() === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
<span class="relative z-10 transition-transform duration-300 group-hover:scale-110">
<Show when={theme.resolvedTheme() === "dark"} fallback={<SunIcon />}>
<MoonIcon />
</Show>
</span>
<span class="absolute inset-0 bg-canvas-subtle scale-0 group-hover:scale-100 transition-transform duration-300 rounded-lg" />
</button>
{/* Language Toggle */}
<button
class="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-display font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-200"
onClick={() => i18n.toggleLocale()}
type="button"
>
{i18n.t("auth.signIn")}
<GlobeIcon />
<span class="uppercase">{i18n.locale()}</span>
</button>
}
>
<div class="h-5 w-px bg-border mx-1" />
{/* Auth */}
<Show
when={auth.session()}
fallback={
<Show when={isDemoMode()} fallback={
<Button
size="sm"
onClick={() => {
setAuthMode("sign-in");
setSignInOpen(true);
}}
>
{i18n.t("auth.signIn")}
</Button>
}>
<Button
size="sm"
variant="secondary"
onClick={async () => {
await auth.signInAsDemo();
navigate("/dashboard");
}}
>
{i18n.locale() === 'cs' ? 'Demo režim - bez přihlášení' : 'Demo mode - no login'}
</Button>
</Show>
}
>
<Button
variant="secondary"
size="sm"
onClick={() => void auth.signOut()}
>
{i18n.t("auth.signOut")}
</Button>
</Show>
</nav>
{/* Mobile Menu Button */}
<button
class="rounded-full border border-black/10 px-4 py-2"
onClick={() => void auth.signOut()}
class="md:hidden p-2.5 rounded-lg text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-200"
onClick={() => setMobileMenuOpen(!mobileMenuOpen())}
type="button"
aria-label={mobileMenuOpen() ? "Close menu" : "Open menu"}
aria-expanded={mobileMenuOpen()}
>
{i18n.t("auth.signOut")}
<Show when={mobileMenuOpen()} fallback={<MenuIcon />}>
<XIcon />
</Show>
</button>
</Show>
</nav>
</div>
</div>
{/* Mobile Menu */}
<Show when={mobileMenuOpen()}>
<div class="md:hidden border-t border-border/60 bg-canvas/95 backdrop-blur-xl animate-slide-down">
<div class="section-container py-4 space-y-2">
{navLinks.map((link) => (
<A
class="block px-4 py-3 text-base font-display font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle rounded-lg transition-all duration-200"
href={link.href}
onClick={() => {
setMobileMenuOpen(false);
window.scrollTo(0, 0);
}}
>
{link.label}
</A>
))}
<div class="pt-2 border-t border-border/60 space-y-2">
<div class="flex items-center gap-4 px-4 py-2">
<button
class="flex items-center gap-2 p-2 rounded-lg text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-200"
onClick={() => theme.toggle()}
type="button"
>
<Show when={theme.resolvedTheme() === "dark"} fallback={<SunIcon />}>
<MoonIcon />
</Show>
<span class="text-sm font-medium">
{theme.resolvedTheme() === "dark" ? "Light mode" : "Dark mode"}
</span>
</button>
<button
class="flex items-center gap-2 p-2 rounded-lg text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-200"
onClick={() => i18n.toggleLocale()}
type="button"
>
<GlobeIcon />
<span class="text-sm font-medium uppercase">{i18n.locale()}</span>
</button>
</div>
<Show
when={auth.session()}
fallback={
<Show when={isDemoMode()} fallback={
<Button
fullWidth
onClick={() => {
setAuthMode("sign-in");
setSignInOpen(true);
setMobileMenuOpen(false);
}}
>
{i18n.t("auth.signIn")}
</Button>
}>
<Button
fullWidth
variant="secondary"
onClick={async () => {
await auth.signInAsDemo();
setMobileMenuOpen(false);
navigate("/dashboard");
}}
>
{i18n.locale() === 'cs' ? 'Demo režim - bez přihlášení' : 'Demo mode - no login'}
</Button>
</Show>
}
>
<Button
variant="secondary"
fullWidth
onClick={() => {
void auth.signOut();
setMobileMenuOpen(false);
}}
>
{i18n.t("auth.signOut")}
</Button>
</Show>
</div>
</div>
</div>
</Show>
</header>
<main class="mx-auto max-w-7xl px-6 pb-16">{props.children}</main>
</Show>
<main class="flex-1">{props.children}</main>
{/* Footer */}
<footer class="border-t border-border/60 py-16 bg-canvas-subtle/40">
<div class="section-container">
<div class="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
{/* Logo & Description */}
<div class="md:col-span-2 space-y-4">
<A href="/" class="inline-flex items-center gap-4 group" onClick={() => window.scrollTo(0, 0)}>
<img
src="/bookra-logo.svg"
alt="Bookra"
class="h-10 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
/>
</A>
<p class="text-ink-muted max-w-sm leading-relaxed">
{i18n.t("footer.description")}
</p>
</div>
{/* Navigation Links */}
<div class="space-y-4">
<h4 class="font-display font-semibold text-ink">{i18n.t("footer.links.title")}</h4>
<ul class="space-y-2">
<li>
<A href="/about" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("nav.about")}
</A>
</li>
<li>
<A href="/contact" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("nav.contact")}
</A>
</li>
<li>
<A href="/#pricing" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("home.pricing.title")}
</A>
</li>
</ul>
</div>
{/* Legal Links */}
<div class="space-y-4">
<h4 class="font-display font-semibold text-ink">{i18n.t("footer.legal.title")}</h4>
<ul class="space-y-2">
<li>
<A href="/privacy" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("footer.privacy")}
</A>
</li>
<li>
<A href="/terms" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("footer.terms")}
</A>
</li>
</ul>
</div>
</div>
{/* Bottom Bar */}
<div class="pt-8 border-t border-border/60 flex flex-col md:flex-row items-center justify-between gap-4">
<p class="text-sm text-ink-subtle font-sans">
{i18n.t("footer.copyright")}
</p>
<div class="flex items-center gap-2 text-sm text-ink-subtle">
<span>Made with</span>
<span class="text-accent"></span>
<span>in Czech Republic</span>
</div>
</div>
</div>
</footer>
<Dialog open={signInOpen()} onClose={() => setSignInOpen(false)}>
<DialogHeader>
<div class="mb-2 flex justify-center">
<BookraCharacter
pose={authMode() === "register" ? "announcement" : "hello"}
size="md"
animate={true}
/>
</div>
<DialogTitle>{authMode() === "register" ? i18n.t("auth.registerTitle") : i18n.t("auth.signInTitle")}</DialogTitle>
<DialogDescription>{authMode() === "register" ? i18n.t("auth.registerBody") : i18n.t("auth.signInBody")}</DialogDescription>
</DialogHeader>
<DialogContent>
<form
class="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void submitSignIn();
}}
>
<Show when={authMode() === "register"}>
<Input
autocomplete="name"
label={i18n.t("auth.fullName")}
onInput={(event) => setName(event.currentTarget.value)}
required
value={name()}
/>
</Show>
<Input
autocomplete="email"
label={i18n.t("auth.email")}
onInput={(event) => setEmail(event.currentTarget.value)}
required
type="email"
value={email()}
/>
<Input
autocomplete="current-password"
label={i18n.t("auth.password")}
onInput={(event) => setPassword(event.currentTarget.value)}
required
type="password"
value={password()}
/>
<Show when={authMode() === "register"}>
<Input
autocomplete="new-password"
label={i18n.t("auth.confirmPassword")}
onInput={(event) => setConfirmPassword(event.currentTarget.value)}
required
type="password"
value={confirmPassword()}
/>
</Show>
<Show when={authError()}>
<p class="rounded-card border border-error/20 bg-error-subtle px-4 py-3 text-sm text-error">
{authError()}
</p>
</Show>
<Show when={authNotice()}>
<p class="rounded-card border border-success/20 bg-success-subtle px-4 py-3 text-sm text-success">
{authNotice()}
</p>
</Show>
<button class="hidden" type="submit" />
</form>
</DialogContent>
<DialogFooter class="flex-col gap-3">
<Show when={showGoogleSignIn()}>
<Button
variant="secondary"
onClick={() => void signInWithGoogle()}
fullWidth
disabled={authSubmitting()}
>
<span class="flex items-center justify-center gap-2">
<GoogleIcon />
{i18n.t("auth.continueWithGoogle")}
</span>
</Button>
</Show>
<Button
isLoading={authSubmitting()}
onClick={() => void submitSignIn()}
fullWidth
class="shadow-lg"
>
{authMode() === "register" ? i18n.t("auth.createAccount") : i18n.t("auth.signIn")}
</Button>
<Button
variant="ghost"
onClick={() => {
setAuthError(null);
setAuthNotice(null);
setAuthMode(authMode() === "register" ? "sign-in" : "register");
}}
fullWidth
>
{authMode() === "register" ? i18n.t("auth.signIn") : i18n.t("auth.createAccount")}
</Button>
</DialogFooter>
</Dialog>
</div>
);
};
@@ -0,0 +1,99 @@
import { JSX, Show, splitProps } from "solid-js";
interface AvatarProps extends JSX.HTMLAttributes<HTMLDivElement> {
src?: string;
alt?: string;
fallback?: string;
size?: "xs" | "sm" | "md" | "lg" | "xl";
variant?: "circle" | "rounded" | "square";
status?: "online" | "offline" | "away" | "busy";
}
export function Avatar(props: AvatarProps) {
const [local, rest] = splitProps(props, [
"src",
"alt",
"fallback",
"size",
"variant",
"status",
"class",
]);
const sizeClasses = {
xs: "w-6 h-6 text-xs",
sm: "w-8 h-8 text-sm",
md: "w-10 h-10 text-base",
lg: "w-12 h-12 text-lg",
xl: "w-16 h-16 text-xl",
};
const variantClasses = {
circle: "rounded-full",
rounded: "rounded-card",
square: "rounded-lg",
};
const getInitials = () => {
if (!local.fallback) return "?";
return local.fallback
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
};
const statusColors = {
online: "bg-success",
offline: "bg-ink-subtle",
away: "bg-warning",
busy: "bg-error",
};
const statusSizeClasses = {
xs: "w-2 h-2",
sm: "w-2.5 h-2.5",
md: "w-3 h-3",
lg: "w-3.5 h-3.5",
xl: "w-4 h-4",
};
return (
<div class="relative inline-flex" {...rest}>
<div
class={[
"relative overflow-hidden bg-accent-subtle flex items-center justify-center",
"font-display font-semibold text-accent",
sizeClasses[local.size || "md"],
variantClasses[local.variant || "circle"],
local.class || "",
].join(" ")}
>
<Show
when={local.src}
fallback={
<span class="select-none">{getInitials()}</span>
}
>
<img
src={local.src}
alt={local.alt || local.fallback || "Avatar"}
class="w-full h-full object-cover"
loading="lazy"
/>
</Show>
</div>
<Show when={local.status}>
<span
class={[
"absolute -bottom-0.5 -right-0.5 rounded-full ring-2 ring-canvas",
statusColors[local.status!],
statusSizeClasses[local.size || "md"],
].join(" ")}
/>
</Show>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { JSX, ParentComponent, splitProps } from "solid-js";
interface BadgeProps extends JSX.HTMLAttributes<HTMLSpanElement> {
variant?: "default" | "secondary" | "outline" | "success" | "warning" | "error";
size?: "sm" | "md";
}
export const Badge: ParentComponent<BadgeProps> = (props) => {
const [local, rest] = splitProps(props, [
"variant",
"size",
"children",
"class",
]);
const variantClasses = {
default: "bg-accent-subtle text-accent border-transparent",
secondary: "bg-canvas-muted text-ink-muted border-transparent",
outline: "bg-transparent text-ink-muted border-border",
success: "bg-[hsl(var(--success-subtle))] text-[hsl(var(--success))] border-transparent",
warning: "bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))] border-transparent",
error: "bg-[hsl(var(--error-subtle))] text-[hsl(var(--error))] border-transparent",
};
const sizeClasses = {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-1 text-sm",
};
const baseClasses = [
"inline-flex items-center gap-1.5 rounded-full font-display font-medium border transition-colors",
variantClasses[local.variant || "default"],
sizeClasses[local.size || "md"],
local.class || "",
].join(" ");
return (
<span {...rest} class={baseClasses}>
{local.children}
</span>
);
};
@@ -0,0 +1,74 @@
import { JSX, ParentComponent, splitProps } from "solid-js";
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
fullWidth?: boolean;
}
export const Button: ParentComponent<ButtonProps> = (props) => {
const [local, rest] = splitProps(props, [
"variant",
"size",
"isLoading",
"fullWidth",
"children",
"class",
]);
const variantClasses = {
primary: "btn-primary",
secondary: "btn-secondary",
ghost: "btn-ghost",
danger: "bg-error text-white border-error hover:bg-error-hover",
};
const sizeClasses = {
sm: "px-4 py-2 text-sm",
md: "px-5 py-2.5 text-[0.9375rem]",
lg: "px-6 py-3 text-base",
};
const baseClasses = [
"inline-flex items-center justify-center gap-2 rounded-button font-display font-medium tracking-tight transition-all duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:transform-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50",
variantClasses[local.variant || "primary"],
sizeClasses[local.size || "md"],
local.fullWidth ? "w-full" : "",
local.class || "",
].join(" ");
return (
<button
{...rest}
class={baseClasses}
disabled={rest.disabled || local.isLoading}
>
{local.isLoading && (
<svg
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{local.children}
</button>
);
};
+101
View File
@@ -0,0 +1,101 @@
import { JSX, ParentComponent, splitProps } from "solid-js";
interface CardProps extends JSX.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "elevated" | "ghost";
padding?: "none" | "sm" | "md" | "lg";
}
export const Card: ParentComponent<CardProps> = (props) => {
const [local, rest] = splitProps(props, [
"variant",
"padding",
"children",
"class",
]);
const variantClasses = {
default: "bg-canvas border border-border rounded-card",
elevated: "surface-elevated rounded-card",
ghost: "bg-transparent",
};
const paddingClasses = {
none: "",
sm: "p-4",
md: "p-6",
lg: "p-8",
};
const baseClasses = [
variantClasses[local.variant || "default"],
paddingClasses[local.padding || "md"],
local.class || "",
].join(" ");
return (
<div {...rest} class={baseClasses}>
{local.children}
</div>
);
};
export const CardHeader: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div {...rest} class={`mb-4 ${local.class || ""}`}>
{local.children}
</div>
);
};
export const CardTitle: ParentComponent<JSX.HTMLAttributes<HTMLHeadingElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<h3
{...rest}
class={`font-display text-lg font-semibold tracking-tight text-ink ${local.class || ""}`}
>
{local.children}
</h3>
);
};
export const CardDescription: ParentComponent<JSX.HTMLAttributes<HTMLParagraphElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<p {...rest} class={`text-sm text-ink-muted mt-1 ${local.class || ""}`}>
{local.children}
</p>
);
};
export const CardContent: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div {...rest} class={local.class || ""}>
{local.children}
</div>
);
};
export const CardFooter: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div
{...rest}
class={`mt-6 pt-6 border-t border-border flex items-center gap-3 ${local.class || ""}`}
>
{local.children}
</div>
);
};
+194
View File
@@ -0,0 +1,194 @@
import { JSX, ParentComponent, Show, createSignal, splitProps, onCleanup, onMount } from "solid-js";
import { Portal } from "solid-js/web";
interface DialogProps {
open: boolean;
onClose: () => void;
children: JSX.Element;
}
export const Dialog: ParentComponent<DialogProps> = (props) => {
const [local] = splitProps(props, ["open", "onClose", "children"]);
let overlayRef: HTMLDivElement | undefined;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && local.open) {
local.onClose();
}
};
const handleOverlayClick = (e: MouseEvent) => {
if (e.target === overlayRef) {
local.onClose();
}
};
onMount(() => {
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown);
});
});
// Lock body scroll when dialog is open
const originalOverflow = document.body.style.overflow;
if (local.open) {
document.body.style.overflow = "hidden";
}
onCleanup(() => {
if (!local.open) {
document.body.style.overflow = originalOverflow;
}
});
return (
<Portal>
<Show when={local.open}>
<div
ref={overlayRef}
class={[
"fixed inset-0 z-50",
"bg-ink/40 backdrop-blur-sm",
"animate-fade-in",
"flex items-center justify-center p-4",
].join(" ")}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
>
<div
class={[
"relative w-full max-w-lg",
"bg-canvas rounded-panel shadow-2xl",
"animate-scale-in",
"border border-border",
"max-h-[90vh] overflow-y-auto",
].join(" ")}
onClick={(e) => e.stopPropagation()}
>
{local.children}
</div>
</div>
</Show>
</Portal>
);
};
// Dialog Header
export const DialogHeader: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div class={["p-6 pb-4", local.class || ""].join(" ")} {...rest}>
{local.children}
</div>
);
};
// Dialog Title
export const DialogTitle: ParentComponent<JSX.HTMLAttributes<HTMLHeadingElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<h2
class={[
"font-display text-xl font-semibold text-ink",
local.class || "",
].join(" ")}
{...rest}
>
{local.children}
</h2>
);
};
// Dialog Description
export const DialogDescription: ParentComponent<JSX.HTMLAttributes<HTMLParagraphElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<p
class={[
"text-sm text-ink-muted mt-1",
local.class || "",
].join(" ")}
{...rest}
>
{local.children}
</p>
);
};
// Dialog Content
export const DialogContent: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div class={["p-6", local.class || ""].join(" ")} {...rest}>
{local.children}
</div>
);
};
// Dialog Footer
export const DialogFooter: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div
class={[
"p-6 pt-4 border-t border-border",
"flex flex-col-reverse sm:flex-row sm:justify-end gap-3",
local.class || "",
].join(" ")}
{...rest}
>
{local.children}
</div>
);
};
// Dialog Close Button
interface DialogCloseButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
onClose: () => void;
}
export const DialogCloseButton: ParentComponent<DialogCloseButtonProps> = (props) => {
const [local, rest] = splitProps(props, ["onClose", "children", "class"]);
return (
<button
class={[
"absolute top-4 right-4",
"p-2 rounded-lg",
"text-ink-muted hover:text-ink hover:bg-canvas-subtle",
"transition-colors duration-200",
local.class || "",
].join(" ")}
onClick={local.onClose}
aria-label="Close dialog"
{...rest}
>
<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="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
);
};
// Hook for dialog state
export function useDialog(initialOpen = false) {
const [isOpen, setIsOpen] = createSignal(initialOpen);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev),
};
}
+11
View File
@@ -0,0 +1,11 @@
export { Button } from "./button";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./card";
export { Badge } from "./badge";
export { Input } from "./input";
export { Select } from "./select";
export { Textarea } from "./textarea";
export { Avatar } from "./avatar";
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
export { Tooltip } from "./tooltip";
export { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter, DialogCloseButton, useDialog } from "./dialog";
export { Skeleton, SkeletonCard, SkeletonText } from "./skeleton";
+40
View File
@@ -0,0 +1,40 @@
import { JSX, splitProps } from "solid-js";
interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
}
export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["label", "error", "hint", "class"]);
return (
<div class="w-full">
{local.label && (
<label class="block font-display text-sm font-medium text-ink mb-2">
{local.label}
</label>
)}
<input
{...rest}
class={[
"w-full bg-canvas border border-border rounded-button px-4 py-3",
"font-sans text-ink placeholder:text-ink-subtle",
"transition-all duration-200",
"hover:border-border-strong",
"focus:outline-none focus:border-accent focus:ring-3 focus:ring-accent/10",
"disabled:opacity-50 disabled:cursor-not-allowed",
local.error ? "border-error focus:border-error focus:ring-error/10" : "",
local.class || "",
].join(" ")}
/>
{local.error && (
<p class="mt-1.5 text-sm text-error">{local.error}</p>
)}
{local.hint && !local.error && (
<p class="mt-1.5 text-sm text-ink-muted">{local.hint}</p>
)}
</div>
);
}
@@ -0,0 +1,93 @@
import { JSX, For, splitProps, createSignal, Show } from "solid-js";
interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps extends Omit<JSX.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
label?: string;
error?: string;
hint?: string;
options: SelectOption[];
onChange?: (value: string) => void;
}
export function Select(props: SelectProps) {
const [local, rest] = splitProps(props, ["label", "error", "hint", "options", "onChange", "class"]);
const [isFocused, setIsFocused] = createSignal(false);
const handleChange = (e: Event) => {
const target = e.target as HTMLSelectElement;
local.onChange?.(target.value);
};
return (
<div class="w-full">
<Show when={local.label}>
<label class="block font-display text-sm font-medium text-ink mb-2">
{local.label}
</label>
</Show>
<div class="relative">
<select
{...rest}
onChange={handleChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
class={[
"w-full appearance-none bg-canvas border rounded-button px-4 py-3 pr-10",
"font-sans text-ink",
"transition-all duration-200",
"hover:border-border-strong",
"focus:outline-none focus:ring-3",
isFocused() ? "border-accent ring-accent/10" : "border-border",
local.error ? "border-error focus:border-error focus:ring-error/10" : "",
"cursor-pointer",
local.class || "",
].join(" ")}
>
<For each={local.options}>
{(option) => (
<option
value={option.value}
disabled={option.disabled}
class="py-2"
>
{option.label}
</option>
)}
</For>
</select>
{/* Custom dropdown arrow */}
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-ink-muted">
<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"
class={isFocused() ? "rotate-180 transition-transform duration-200" : "transition-transform duration-200"}
>
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
</div>
<Show when={local.error}>
<p class="mt-1.5 text-sm text-error">{local.error}</p>
</Show>
<Show when={local.hint && !local.error}>
<p class="mt-1.5 text-sm text-ink-muted">{local.hint}</p>
</Show>
</div>
);
}
@@ -0,0 +1,50 @@
import { JSX } from "solid-js";
interface SkeletonProps extends JSX.HTMLAttributes<HTMLDivElement> {
width?: string;
height?: string;
circle?: boolean;
}
export function Skeleton(props: SkeletonProps) {
const { width, height, circle, class: className, ...rest } = props;
return (
<div
{...rest}
class={[
"animate-pulse bg-canvas-muted rounded-lg",
circle ? "rounded-full" : "",
className || "",
].join(" ")}
style={{
width: width || "100%",
height: height || "1rem",
}}
/>
);
}
export function SkeletonCard() {
return (
<div class="p-6 rounded-card border border-border bg-canvas/50 space-y-4">
<Skeleton width="60%" height="1.5rem" />
<Skeleton width="100%" height="1rem" />
<Skeleton width="80%" height="1rem" />
</div>
);
}
export function SkeletonText(props: { lines?: number }) {
const lines = props.lines || 3;
return (
<div class="space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
width={i === lines - 1 ? "75%" : "100%"}
height="1rem"
/>
))}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { JSX, For, createSignal, createContext, useContext, ParentComponent, splitProps, children, Accessor } from "solid-js";
import type { ResolvedChildren } from "solid-js";
// Context for tab state
interface TabsContextValue {
selectedTab: Accessor<string>;
setSelectedTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue>();
const useTabs = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error("useTabs must be used within a Tabs component");
}
return context;
};
// Tabs Root Component
interface TabsProps {
defaultValue: string;
value?: string;
onValueChange?: (value: string) => void;
children: JSX.Element;
class?: string;
}
export const Tabs: ParentComponent<TabsProps> = (props) => {
const [local, rest] = splitProps(props, ["defaultValue", "value", "onValueChange", "children", "class"]);
const [selectedTab, setSelectedTabInternal] = createSignal(local.defaultValue);
const setSelectedTab = (id: string) => {
setSelectedTabInternal(id);
local.onValueChange?.(id);
};
// If controlled, use the provided value
const currentTab = () => local.value ?? selectedTab();
const contextValue: TabsContextValue = {
selectedTab: currentTab,
setSelectedTab,
};
return (
<TabsContext.Provider value={contextValue}>
<div class={local.class} {...rest}>
{local.children}
</div>
</TabsContext.Provider>
);
};
// Tabs List Component
interface TabsListProps extends JSX.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "pills" | "underline";
}
export const TabsList: ParentComponent<TabsListProps> = (props) => {
const [local, rest] = splitProps(props, ["variant", "children", "class"]);
const variantClasses = {
default: "bg-canvas-muted p-1 rounded-card",
pills: "gap-1",
underline: "border-b border-border gap-4",
};
return (
<div
class={[
"flex items-center",
variantClasses[local.variant || "default"],
local.class || "",
].join(" ")}
role="tablist"
{...rest}
>
{local.children}
</div>
);
};
// Tab Trigger Component
interface TabsTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
}
export const TabsTrigger: ParentComponent<TabsTriggerProps> = (props) => {
const [local, rest] = splitProps(props, ["value", "children", "class"]);
const tabs = useTabs();
const isSelected = () => tabs.selectedTab() === local.value;
return (
<button
role="tab"
aria-selected={isSelected()}
class={[
"px-4 py-2 text-sm font-display font-medium transition-all duration-200",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2",
"rounded-button",
isSelected()
? "bg-canvas text-ink shadow-sm"
: "text-ink-muted hover:text-ink hover:bg-canvas-subtle",
local.class || "",
].join(" ")}
onClick={() => tabs.setSelectedTab(local.value)}
{...rest}
>
{local.children}
</button>
);
};
// Tab Content Component
interface TabsContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: string;
}
export const TabsContent: ParentComponent<TabsContentProps> = (props) => {
const [local, rest] = splitProps(props, ["value", "children", "class"]);
const tabs = useTabs();
const isSelected = () => tabs.selectedTab() === local.value;
return (
<div
role="tabpanel"
class={[
"mt-4",
isSelected() ? "block animate-fade-in" : "hidden",
local.class || "",
].join(" ")}
{...rest}
>
{local.children}
</div>
);
};
@@ -0,0 +1,57 @@
import { JSX, splitProps, createSignal } from "solid-js";
interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
rows?: number;
resize?: "none" | "vertical" | "horizontal" | "both";
}
export function Textarea(props: TextareaProps) {
const [local, rest] = splitProps(props, ["label", "error", "hint", "class", "rows", "resize"]);
const [isFocused, setIsFocused] = createSignal(false);
const resizeClass = () => {
switch (local.resize) {
case "none": return "resize-none";
case "vertical": return "resize-y";
case "horizontal": return "resize-x";
case "both": return "resize";
default: return "resize-y";
}
};
return (
<div class="w-full">
{local.label && (
<label class="block font-display text-sm font-medium text-ink mb-2">
{local.label}
</label>
)}
<textarea
{...rest}
rows={local.rows || 4}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
class={[
"w-full bg-canvas border rounded-button px-4 py-3",
"font-sans text-ink placeholder:text-ink-subtle",
"transition-all duration-200",
"hover:border-border-strong",
"focus:outline-none focus:ring-3",
isFocused() ? "border-accent ring-accent/10" : "border-border",
local.error ? "border-error focus:border-error focus:ring-error/10" : "",
resizeClass(),
local.class || "",
].join(" ")}
/>
{local.error && (
<p class="mt-1.5 text-sm text-error">{local.error}</p>
)}
{local.hint && !local.error && (
<p class="mt-1.5 text-sm text-ink-muted">{local.hint}</p>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import { JSX, ParentComponent, Show, createSignal, splitProps } from "solid-js";
interface TooltipProps {
content: string | JSX.Element;
placement?: "top" | "bottom" | "left" | "right";
delay?: number;
children: JSX.Element;
}
export const Tooltip: ParentComponent<TooltipProps> = (props) => {
const [local] = splitProps(props, ["content", "placement", "delay", "children"]);
const [isVisible, setIsVisible] = createSignal(false);
const [timeoutId, setTimeoutId] = createSignal<number | null>(null);
const placementClasses = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
const arrowClasses = {
top: "top-full left-1/2 -translate-x-1/2 -mt-1 border-t-canvas-elevated",
bottom: "bottom-full left-1/2 -translate-x-1/2 -mb-1 border-b-canvas-elevated",
left: "left-full top-1/2 -translate-y-1/2 -ml-1 border-l-canvas-elevated",
right: "right-full top-1/2 -translate-y-1/2 -mr-1 border-r-canvas-elevated",
};
const handleMouseEnter = () => {
const id = window.setTimeout(() => {
setIsVisible(true);
}, local.delay || 200);
setTimeoutId(id);
};
const handleMouseLeave = () => {
const id = timeoutId();
if (id) {
window.clearTimeout(id);
setTimeoutId(null);
}
setIsVisible(false);
};
return (
<div
class="relative inline-flex"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleMouseEnter}
onBlur={handleMouseLeave}
>
{local.children}
<Show when={isVisible()}>
<div
class={[
"absolute z-50 px-3 py-2",
"bg-canvas-elevated text-ink text-sm",
"font-sans rounded-lg shadow-lg border border-border",
"animate-scale-in origin-center",
placementClasses[local.placement || "top"],
].join(" ")}
role="tooltip"
>
{local.content}
{/* Arrow */}
<div
class={[
"absolute w-2 h-2 rotate-45",
"bg-canvas-elevated border border-border",
arrowClasses[local.placement || "top"],
].join(" ")}
/>
</div>
</Show>
</div>
);
};
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
export type MapSource = "mapy.cz" | "google-maps" | "coordinates" | "geocoded" | "unknown";
export interface MapCoordinates {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
source: MapSource;
street?: string;
houseNumber?: string;
city?: string;
zip?: string;
country?: string;
}
export interface MapTileStyle {
id: string;
name: string;
description: string;
url: string;
attribution: string;
tileClassName?: string;
}
export const DEFAULT_MAP_STYLE_ID = "bookra-voyager";
export const MAP_TILE_STYLES: readonly MapTileStyle[] = [
{
id: "bookra-voyager",
name: "Bookra Voyager",
description: "Warm, calm default that fits Bookra surfaces.",
url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
attribution: "&copy; OpenStreetMap contributors &copy; CARTO",
tileClassName: "bookra-map-tiles-warm",
},
{
id: "light",
name: "Clean Light",
description: "Quiet light basemap with high readability.",
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
attribution: "&copy; OpenStreetMap contributors &copy; CARTO",
},
{
id: "dark",
name: "Dark Matter",
description: "Dark basemap for dark sections and evening brands.",
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
attribution: "&copy; OpenStreetMap contributors &copy; CARTO",
},
{
id: "voyager",
name: "Voyager",
description: "Balanced color and street detail.",
url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
attribution: "&copy; OpenStreetMap contributors &copy; CARTO",
},
{
id: "openstreetmap",
name: "OpenStreetMap",
description: "Standard OSM tiles.",
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "&copy; OpenStreetMap contributors",
},
{
id: "satellite",
name: "Satellite",
description: "Esri satellite imagery.",
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: "Tiles &copy; Esri",
},
];
export const MAP_STYLE_OPTIONS = [
...MAP_TILE_STYLES.map((style) => ({
value: style.id,
label: style.name,
})),
{
value: "custom",
label: "Custom tile URL",
},
];
export function mapStyleById(styleId: string | undefined): MapTileStyle {
return MAP_TILE_STYLES.find((style) => style.id === styleId) ?? MAP_TILE_STYLES[0];
}
export function resolveMapTileStyle(styleId: string | undefined, customTileUrl?: string): MapTileStyle {
const trimmedUrl = customTileUrl?.trim();
if (styleId === "custom" && trimmedUrl) {
return {
id: "custom",
name: "Custom",
description: "User-provided tile URL.",
url: trimmedUrl,
attribution: "&copy; OpenStreetMap contributors",
};
}
return mapStyleById(styleId);
}
export function validateCoordinates(lat: number, lng: number): boolean {
return (
Number.isFinite(lat) &&
Number.isFinite(lng) &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180
);
}
export function parseCoordinateText(input: string): MapCoordinates | null {
const match = input
.trim()
.match(/^\s*(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)\s*(?:[,;\s]\s*(\d{1,2}))?\s*$/);
if (!match) return null;
const latitude = Number(match[1]);
const longitude = Number(match[2]);
const zoom = match[3] ? Number(match[3]) : undefined;
if (!validateCoordinates(latitude, longitude)) return null;
return {
latitude,
longitude,
zoom: Number.isFinite(zoom) ? zoom : undefined,
source: "coordinates",
};
}
export function parseMapyCzUrl(url: string): MapCoordinates | null {
try {
const urlObj = new URL(url);
if (!urlObj.hostname.includes("mapy.cz") && !urlObj.hostname.includes("mapy.com")) {
return null;
}
const longitude = Number(urlObj.searchParams.get("x"));
const latitude = Number(urlObj.searchParams.get("y"));
const zoom = Number(urlObj.searchParams.get("z"));
const address = urlObj.searchParams.get("q") ?? undefined;
if (!validateCoordinates(latitude, longitude)) return null;
return {
latitude,
longitude,
zoom: Number.isFinite(zoom) ? zoom : undefined,
address: address ? decodeURIComponent(address) : undefined,
source: "mapy.cz",
};
} catch {
return null;
}
}
export function parseGoogleMapsUrl(url: string): MapCoordinates | null {
try {
const urlObj = new URL(url);
if (
!urlObj.hostname.includes("google.") &&
!urlObj.hostname.includes("goo.gl") &&
!urlObj.hostname.includes("maps.app.goo.gl")
) {
return null;
}
const pathMatch = urlObj.href.match(/@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(\d+)(?:[mz])/);
if (pathMatch) {
const latitude = Number(pathMatch[1]);
const longitude = Number(pathMatch[2]);
const zoom = Number(pathMatch[3]);
if (validateCoordinates(latitude, longitude)) {
const placeMatch = urlObj.pathname.match(/\/place\/([^/]+)/);
return {
latitude,
longitude,
zoom: Number.isFinite(zoom) ? zoom : undefined,
address: placeMatch ? decodeURIComponent(placeMatch[1].replace(/\+/g, " ")) : undefined,
source: "google-maps",
};
}
}
const qParam = urlObj.searchParams.get("q");
if (qParam) {
const coordinateResult = parseCoordinateText(qParam);
if (coordinateResult) {
return {
...coordinateResult,
source: "google-maps",
};
}
}
const dataMatch = urlObj.href.match(/!3d(-?\d+(?:\.\d+)?)!4d(-?\d+(?:\.\d+)?)/);
if (dataMatch) {
const latitude = Number(dataMatch[1]);
const longitude = Number(dataMatch[2]);
if (validateCoordinates(latitude, longitude)) {
return {
latitude,
longitude,
source: "google-maps",
};
}
}
return null;
} catch {
return null;
}
}
export function parseMapUrl(input: string): MapCoordinates | null {
if (!input.trim()) return null;
let normalizedUrl = input.trim();
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
normalizedUrl = `https://${normalizedUrl}`;
}
return parseMapyCzUrl(normalizedUrl) ?? parseGoogleMapsUrl(normalizedUrl);
}
export async function geocodeLocation(query: string, signal?: AbortSignal): Promise<MapCoordinates | null> {
const trimmed = query.trim();
if (!trimmed) return null;
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&addressdetails=1&accept-language=cs,en&q=${encodeURIComponent(trimmed)}`,
{ signal },
);
if (!response.ok) {
throw new Error("Location search failed");
}
const results = (await response.json()) as Array<{
lat?: string;
lon?: string;
display_name?: string;
address?: {
road?: string;
street?: string;
pedestrian?: string;
footway?: string;
house_number?: string;
city?: string;
town?: string;
village?: string;
municipality?: string;
postcode?: string;
country?: string;
};
}>;
const first = results[0];
if (!first) return null;
const latitude = Number(first.lat);
const longitude = Number(first.lon);
if (!validateCoordinates(latitude, longitude)) return null;
const address = first.address ?? {};
return {
latitude,
longitude,
zoom: 16,
address: first.display_name ?? trimmed,
source: "geocoded",
street: address.road ?? address.street ?? address.pedestrian ?? address.footway,
houseNumber: address.house_number,
city: address.city ?? address.town ?? address.village ?? address.municipality,
zip: address.postcode,
country: address.country,
};
}
export async function reverseGeocode(lat: number, lng: number, signal?: AbortSignal): Promise<Partial<MapCoordinates>> {
if (!validateCoordinates(lat, lng)) return {};
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}&addressdetails=1&accept-language=cs,en`,
{ signal },
);
if (!response.ok) return {};
const data = (await response.json()) as {
display_name?: string;
address?: {
road?: string;
street?: string;
pedestrian?: string;
footway?: string;
house_number?: string;
city?: string;
town?: string;
village?: string;
municipality?: string;
postcode?: string;
country?: string;
};
};
const address = data.address ?? {};
return {
address: data.display_name,
street: address.road ?? address.street ?? address.pedestrian ?? address.footway,
houseNumber: address.house_number,
city: address.city ?? address.town ?? address.village ?? address.municipality,
zip: address.postcode,
country: address.country,
};
}
export async function resolveLocationInput(input: string, signal?: AbortSignal): Promise<MapCoordinates | null> {
const coordinateResult = parseCoordinateText(input);
if (coordinateResult) {
const reverse = await reverseGeocode(coordinateResult.latitude, coordinateResult.longitude, signal).catch(() => ({}));
return {
...coordinateResult,
...reverse,
};
}
const urlResult = parseMapUrl(input);
if (urlResult) {
const reverse = urlResult.address
? {}
: await reverseGeocode(urlResult.latitude, urlResult.longitude, signal).catch(() => ({}));
return {
...urlResult,
...reverse,
};
}
return geocodeLocation(input, signal);
}
+23
View File
@@ -0,0 +1,23 @@
import { initializePaddle, type Paddle } from "@paddle/paddle-js";
const paddleToken = import.meta.env.VITE_PADDLE_CLIENT_TOKEN ?? "";
const paddleEnvironment = (import.meta.env.VITE_PADDLE_ENV ?? "sandbox").toLowerCase() === "live" ? "production" : "sandbox";
let paddlePromise: Promise<Paddle | undefined> | null = null;
export function paddleConfigured() {
return paddleToken.trim() !== "";
}
export async function getPaddle() {
if (!paddleConfigured()) {
return undefined;
}
if (!paddlePromise) {
paddlePromise = initializePaddle({
environment: paddleEnvironment,
token: paddleToken,
});
}
return paddlePromise;
}
+20 -4
View File
@@ -1,14 +1,30 @@
import { render } from "solid-js/web";
import { Router } from "@solidjs/router";
import { Route, Router } from "@solidjs/router";
import App from "./App";
import { AboutRoute } from "./routes/about-route";
import { AuthCallbackRoute } from "./routes/auth-callback-route";
import { BookingManageRoute } from "./routes/booking-manage-route";
import { ContactRoute } from "./routes/contact-route";
import { DashboardRoute } from "./routes/dashboard-route";
import { HomeRoute } from "./routes/home-route";
import { LegalRoute } from "./routes/legal-route";
import { NotFoundRoute } from "./routes/not-found-route";
import { PublicBookingRoute } from "./routes/public-booking-route";
import "./styles/index.css";
render(
() => (
<Router>
<App />
<Router root={App}>
<Route path="/" component={HomeRoute} />
<Route path="/about" component={AboutRoute} />
<Route path="/auth/callback" component={AuthCallbackRoute} />
<Route path="/contact" component={ContactRoute} />
<Route path="/dashboard" component={DashboardRoute} />
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
<Route path="/manage/:reference" component={BookingManageRoute} />
<Route path="/:kind" component={LegalRoute} matchFilters={{ kind: ["privacy", "terms"] }} />
<Route path="*404" component={NotFoundRoute} />
</Router>
),
document.getElementById("root")!,
);
+147 -25
View File
@@ -10,12 +10,57 @@ import type { AuthSession } from "../lib/types";
const neonAuthUrl = import.meta.env.VITE_NEON_AUTH_URL ?? "";
const authClient = neonAuthUrl ? createAuthClient(neonAuthUrl) : null;
const localAuthTokenKey = "bookra.localAuthToken";
type LocalUser = {
id: string;
email?: string;
name?: string;
};
function sessionFromLocalToken(token: string, user?: LocalUser): AuthSession {
const payload = parseJwtPayload(token);
const expiresAt = typeof payload.exp === "number" ? new Date(payload.exp * 1000) : undefined;
return {
session: {
id: token,
userId: String(payload.sub ?? user?.id ?? ""),
token,
expiresAt,
},
user: {
id: String(payload.sub ?? user?.id ?? ""),
email: String(payload.email ?? user?.email ?? ""),
name: String(payload.name ?? user?.name ?? ""),
image: null,
},
};
}
function parseJwtPayload(token: string): Record<string, unknown> {
try {
const payload = token.split(".")[1];
if (!payload) return {};
const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "=");
return JSON.parse(atob(padded)) as Record<string, unknown>;
} catch {
return {};
}
}
type AuthContextValue = {
session: () => AuthSession | null;
loading: () => boolean;
usesNeonAuth: () => boolean;
supportsMagicLink: () => boolean;
supportsGoogleSignIn: () => boolean;
getToken: () => Promise<string | null>;
signInDemo: () => Promise<void>;
signUpWithEmail: (name: string, email: string, password: string) => Promise<void>;
signInWithEmail: (email: string, password: string) => Promise<void>;
signInAsDemo: () => Promise<void>;
sendMagicLink: (email: string) => Promise<void>;
signInWithMagicLink: (token: string, refreshToken?: string) => Promise<void>;
signInWithGoogle: () => Promise<void>;
signOut: () => Promise<void>;
};
@@ -25,9 +70,27 @@ export const AuthProvider: ParentComponent = (props) => {
const [session, setSession] = createSignal<AuthSession | null>(null);
const [loading, setLoading] = createSignal(true);
const clearLocalSession = () => {
localStorage.removeItem(localAuthTokenKey);
setSession(null);
};
createEffect(() => {
void (async () => {
const demoToken = localStorage.getItem(localAuthTokenKey);
if (demoToken?.startsWith("demo.")) {
const demoUser: LocalUser = {
id: "demo-user-id",
email: "demo@bookra.io",
name: "Demo User",
};
setSession(sessionFromLocalToken(demoToken, demoUser));
setLoading(false);
return;
}
if (!authClient) {
setSession(null);
setLoading(false);
return;
}
@@ -43,40 +106,99 @@ export const AuthProvider: ParentComponent = (props) => {
})();
});
const requireNeonAuth = () => {
if (!authClient) {
throw new Error("Neon Auth is not configured for this environment.");
}
return authClient;
};
const value: AuthContextValue = {
session,
loading,
usesNeonAuth: () => Boolean(authClient),
supportsMagicLink: () => false,
supportsGoogleSignIn: () => Boolean(authClient),
async getToken() {
if (!authClient) return null;
const demoToken = localStorage.getItem(localAuthTokenKey);
if (demoToken?.startsWith("demo.")) {
return demoToken;
}
if (!authClient) {
return session()?.session?.token ?? null;
}
const jwtToken = await (
authClient as unknown as { getJWTToken?: () => Promise<string | null | undefined> }
).getJWTToken?.();
if (typeof jwtToken === "string" && jwtToken.trim() !== "") {
return jwtToken;
}
return session()?.session?.token ?? null;
},
async signInDemo() {
if (!authClient) {
setSession({
user: {
id: "demo-owner",
email: "owner@bookra.dev",
name: "Bookra Demo Owner",
},
session: {
id: "demo-session",
userId: "demo-owner",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
return;
}
await authClient.signIn.email({
email: "owner@bookra.dev",
password: "bookra-demo-password",
async signUpWithEmail(name: string, email: string, password: string) {
const client = requireNeonAuth();
await client.signUp.email({
email,
password,
name,
});
const response = await authClient.getSession();
const response = await client.getSession();
setSession((response?.data as unknown as AuthSession | undefined) ?? null);
},
async signInWithEmail(email: string, password: string) {
const client = requireNeonAuth();
await client.signIn.email({
email,
password,
});
const response = await client.getSession();
setSession((response?.data as unknown as AuthSession | undefined) ?? null);
},
async signInAsDemo() {
const demoToken =
"demo." +
btoa(
JSON.stringify({
sub: "demo-user-id",
email: "demo@bookra.io",
name: "Demo User",
exp: Math.floor(Date.now() / 1000) + 86400,
iat: Math.floor(Date.now() / 1000),
demo: true,
}),
).replace(/=/g, "") +
".demo-signature";
const demoUser: LocalUser = {
id: "demo-user-id",
email: "demo@bookra.io",
name: "Demo User",
};
localStorage.setItem(localAuthTokenKey, demoToken);
setSession(sessionFromLocalToken(demoToken, demoUser));
},
async sendMagicLink() {
throw new Error("Magic link sign-in is not used in this app.");
},
async signInWithMagicLink() {
throw new Error("Magic link callback is not used in this app.");
},
async signInWithGoogle() {
const client = requireNeonAuth();
await client.signIn.social({
provider: "google",
callbackURL: `${window.location.origin}/dashboard`,
});
},
async signOut() {
if (!authClient) return;
await authClient.signOut();
setSession(null);
try {
if (authClient) {
await authClient.signOut();
}
} finally {
clearLocalSession();
}
},
};
+632 -66
View File
@@ -10,82 +10,576 @@ import type { Locale } from "@bookra/shared-types";
const dictionaries = {
cs: {
// Navigation & Auth
"nav.booking": "Veřejná rezervace",
"nav.dashboard": "Aplikace",
"auth.signIn": "Přihlásit",
"auth.signOut": "Odhlásit",
"home.eyebrow": "Bookra",
"home.title": "Klidný rezervační software pro lokální služby.",
"home.body":
"Root je marketingový vstup do produktu. Hlavní aplikace začíná v dashboardu a veřejná rezervace zůstává oddělená pro zákazníky.",
"home.primary": "Otevřít aplikaci",
"home.secondary": "Zobrazit veřejnou rezervaci",
"home.appLabel": "Hlavní vstup",
"home.appTitle": "/dashboard",
"home.appBody":
"Majitelé a tým pokračují přímo do aplikace, kde řeší dashboard, billing, tenant bootstrap a provoz.",
"home.publicLabel": "Veřejný tok",
"home.publicTitle": "/book/:tenantSlug",
"home.publicBody":
"Customer-facing booking flow zůstává mimo aplikaci, aby byl rychlý, čistý a bez interního šumu.",
"dashboard.title": "Owner dashboard",
"dashboard.body":
"Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
"dashboard.kpi.bookings": "Bookings this week",
"dashboard.kpi.cancellations": "Cancellations",
"dashboard.kpi.utilization": "Utilization",
"dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
"dashboard.bootstrap": "Tenant bootstrap",
"dashboard.previewMode": "Preview mode",
"dashboard.billing": "Billing",
"dashboard.checkout": "Open checkout",
"dashboard.refreshBilling": "Refresh billing",
"dashboard.plan": "Plan",
"dashboard.status": "Status",
"dashboard.entitlements": "Entitlements",
"nav.about": "O nás",
"nav.contact": "Kontakt",
// Calendar
"calendar.months.0": "Leden",
"calendar.months.1": "Únor",
"calendar.months.2": "Březen",
"calendar.months.3": "Duben",
"calendar.months.4": "Květen",
"calendar.months.5": "Červen",
"calendar.months.6": "Červenec",
"calendar.months.7": "Srpen",
"calendar.months.8": "Září",
"calendar.months.9": "Říjen",
"calendar.months.10": "Listopad",
"calendar.months.11": "Prosinec",
"calendar.days.0": "Po",
"calendar.days.1": "Út",
"calendar.days.2": "St",
"calendar.days.3": "Čt",
"calendar.days.4": "Pá",
"calendar.days.5": "So",
"calendar.days.6": "Ne",
"calendar.prevMonth": "Předchozí měsíc",
"calendar.nextMonth": "Další měsíc",
"common.cancel": "Zrušit",
"auth.signIn": "Přihlásit se",
"auth.signOut": "Odhlásit se",
"auth.signInTitle": "Přihlášení",
"auth.signInBody": "Použijte účet vytvořený v Bookra Auth.",
"auth.email": "E-mail",
"auth.password": "Heslo",
"auth.signInFailed": "Přihlášení se nepodařilo.",
"auth.magicLinkSent": "Magický odkaz odeslán na váš e-mail",
"auth.magicLinkTitle": "Přihlášení bez hesla",
"auth.magicLinkBody": "Zadejte svůj e-mail a pošleme vám bezpečný odkaz pro přihlášení.",
"auth.orContinueWith": "nebo pokračujte s",
"auth.continueWithGoogle": "Pokračovat s Google",
"auth.noAccount": "Nemáte účet?",
"auth.createAccount": "Vytvořit účet",
"auth.forgotPassword": "Zapomněli jste heslo?",
"auth.registerTitle": "Vytvořit účet",
"auth.registerBody": "Začněte spravovat své rezervace s Bookra.",
"auth.fullName": "Celé jméno",
"auth.confirmPassword": "Potvrdit heslo",
"auth.passwordMismatch": "Hesla se neshodují",
"auth.emailInvalid": "Zadejte platný e-mail",
"auth.passwordTooShort": "Heslo musí mít alespoň 8 znaků",
"auth.accountCreated": "Účet vytvořen",
"auth.checkEmail": "Zkontrolujte svůj e-mail pro potvrzení",
"auth.magicLink.checkInbox": "Zkontrolujte svou e-mailovou schránku",
"auth.magicLink.instructions": "Klikněte na odkaz v e-mailu pro přihlášení. Odkaz platí 15 minut.",
"auth.magicLink.didntReceive": "Nepřišel e-mail?",
"auth.magicLink.resend": "Poslat znovu",
"auth.magicLink.backToSignIn": "Zpět na přihlášení",
"auth.errors.invalidCredentials": "Neplatné přihlašovací údaje",
"auth.errors.emailExists": "Tento e-mail je již registrován",
"auth.errors.tokenExpired": "Odkaz vypršel, požádejte o nový",
"auth.errors.tokenInvalid": "Neplatný odkaz, zkuste to znovu",
"auth.welcome": "Vítejte v Bookra",
"auth.welcomeBody": "Děkujeme za registraci. Vaše rezervace budou nyní mnohem jednodušší.",
// Hero Section
"home.badge": "Nyní zdarma pro začátečníky",
"home.hero.title": "Klidný rezervační software pro lokální služby",
"home.hero.subtitle": "Spravujte rezervace, zákazníky a tým na jednom místě. Bez zbytečné složitosti — jen spolehlivý systém, který funguje.",
"home.hero.cta.primary": "Začít zdarma",
"home.hero.cta.secondary": "Otevřít rezervaci",
"home.trust": "Důvěřují nám podniky po celé ČR",
// Features Section
"home.features.eyebrow": "Funkce",
"home.features.title": "Vše potřebné pro váš podnik",
"home.features.subtitle": "Od správy rezervací po e-mailová připomenutí — máte vše, co potřebujete k efektivnímu provozu.",
"home.feature.scheduling.title": "Chytré plánování",
"home.feature.scheduling.desc": "Automatické detekce konfliktů, buffer mezi rezervacemi a správa více lokací.",
"home.feature.customers.title": "Správa zákazníků",
"home.feature.customers.desc": "Uchovávejte historii návštěv, preference a kontakty na jednom místě.",
"home.feature.reminders.title": "Automatická připomenutí",
"home.feature.reminders.desc": "E-mailová upozornění pro vás i zákazníky. Méně zapomenutých rezervací.",
"home.feature.availability.title": "Flexibilní dostupnost",
"home.feature.availability.desc": "Nastavte pracovní dobu, výjimky a black-out dny podle vašich potřeb.",
"home.feature.quick.title": "Rychlé rezervace",
"home.feature.quick.desc": "Zákazníci si zarezervují termín za méně než 60 sekund. Bez registrace nutné.",
"home.feature.secure.title": "Bezpečné údaje",
"home.feature.secure.desc": "GDPR kompatibilní, šifrovaná data, zálohy a plná kontrola nad soukromím.",
// How It Works
"home.how.eyebrow": "Jak to funguje",
"home.how.title": "Začněte za 5 minut",
"home.how.subtitle": "Žádné složité nastavování. Vytvořte si profil, přidejte služby a začněte přijímat rezervace ještě dnes.",
"home.how.cta": "Vytvořit účet",
"home.step1.title": "Vytvořte si profil",
"home.step1.desc": "Zadejte název podniku, lokaci a kontaktní údaje. Trvá to méně než minutu.",
"home.step2.title": "Přidejte služby a tým",
"home.step2.desc": "Nastavte služby, ceny, pracovní dobu a pozvěte členy týmu.",
"home.step3.title": "Spusťte rezervace",
"home.step3.desc": "Získáte unikátní odkaz, který můžete sdílet se zákazníky nebo vložit na web.",
"home.step4.title": "To je vše!",
"home.step4.desc": "Začnete přijímat rezervace okamžitě.",
// Testimonials
"home.testimonials.eyebrow": "Reference",
"home.testimonials.title": "Co říkají naši zákazníci",
"home.testimonial1.quote": "Bookra nám ušetřila hodiny administrativy týdně. Zákazníci milují jednoduchost online rezervací.",
"home.testimonial1.author": "Martina Nováková",
"home.testimonial1.role": "Majitelka, Salon Ella",
"home.testimonial2.quote": "Konec dvojitým rezervacím a zmatkům v diáři. Vše je přehledné a pod kontrolou.",
"home.testimonial2.author": "David Svoboda",
"home.testimonial2.role": "Fyzioterapeut, Physio Care",
"home.testimonial3.quote": "Automatická e-mailová připomenutí snížila počet zapomenutých rezervací. Skvělá investice.",
"home.testimonial3.author": "Jana Kovářová",
"home.testimonial3.role": "Majitelka, Massage Studio",
// Pricing
"home.pricing.eyebrow": "Ceník",
"home.pricing.title": "Jednoduché a transparentní ceny",
"home.pricing.subtitle": "Žádné skryté poplatky. Všechny plány zahrnují plnou podporu.",
"home.pricing.starter.name": "Starter",
"home.pricing.starter.desc": "Pro jednotlivce a malé podniky",
"home.pricing.starter.f1": "Do 50 rezervací/měsíc",
"home.pricing.starter.f2": "1 lokace, 1 zaměstnanec",
"home.pricing.starter.f3": "E-mailová podpora",
"home.pricing.starter.cta": "Začít zdarma",
"home.pricing.starter.trial": "15 dní zdarma po registraci",
"home.pricing.perMonth": "/měsíc",
"home.pricing.pro.name": "Pro",
"home.pricing.pro.desc": "Pro rostoucí podniky",
"home.pricing.popular": "Nejoblíbenější",
"home.pricing.pro.f1": "Neomezené rezervace",
"home.pricing.pro.f2": "3 lokace, 10 zaměstnanců",
"home.pricing.pro.f3": "E-mailová připomenutí",
"home.pricing.pro.f4": "Prioritní podpora",
"home.pricing.pro.f5": "Analytika a reporty",
"home.pricing.pro.cta": "Začít 15denní zkoušku",
"home.pricing.pro.trial": "15 dní zdarma po registraci",
"home.pricing.biz.name": "Business",
"home.pricing.biz.desc": "Pro větší týmy a franšízy",
"home.pricing.biz.f1": "Neomezené vše",
"home.pricing.biz.f2": "Více lokací",
"home.pricing.biz.f3": "API přístup",
"home.pricing.biz.f4": "Dedikovaný manažer",
"home.pricing.biz.cta": "Kontaktovat prodej",
"home.pricing.biz.trial": "Individuální řešení na míru",
// CTA
"home.cta.title": "Připraveni zjednodušit své rezervace?",
"home.cta.subtitle": "Připojte se k tisícům podniků, které šetří čas s Bookra.",
"home.cta.primary": "Začít zdarma",
"home.cta.secondary": "Otevřít rezervaci",
// Widget Builder
"widget.builder.title": "Rezervační widget",
"widget.builder.subtitle": "Vyberte styl a zkopírujte kód na váš web",
"widget.types.title": "Dostupné widgety",
"widget.types.desc": "Přetáhněte pro změnu pořadí, klikněte pro výběr",
"widget.type.iframe.title": "Inline iframe",
"widget.type.iframe.desc": "Vložte rezervační formulář přímo do stránky",
"widget.type.iframe.preview": "Nejlepší pro: Hlavní stránky, rezervační sekce",
"widget.type.button.title": "Rezervační tlačítko",
"widget.type.button.desc": "Jednoduché tlačítko odkazující na rezervaci",
"widget.type.button.preview": "Nejlepší pro: Navigace, CTA sekce",
"widget.type.inline.title": "Inline kalendář",
"widget.type.inline.desc": "Interaktivní kalendář přímo na vašem webu",
"widget.type.inline.preview": "Nejlepší pro: Stránky služeb, produkty",
"widget.type.modal.title": "Modal popup",
"widget.type.modal.desc": "Rezervace se otevře v překrývacím okně",
"widget.type.modal.preview": "Nejlepší pro: Rychlý přístup bez opuštění stránky",
"widget.type.floating.title": "Plovoucí bublina",
"widget.type.floating.desc": "Plovoucí tlačítko v rohu obrazovky",
"widget.type.floating.preview": "Nejlepší pro: E-shopy, kontinuální dostupnost",
"widget.button.text": "Rezervovat termín",
"widget.modal.trigger": "Otevřít rezervaci",
"widget.styling.title": "Vzhled",
"widget.styling.color": "Primární barva",
"widget.styling.radius": "Zaoblení rohů",
"widget.styling.shadow": "Intenzita stínu",
"widget.styling.buttonText": "Text tlačítka",
"widget.styling.position": "Pozice",
"widget.position.topLeft": "Vlevo nahoře",
"widget.position.topRight": "Vpravo nahoře",
"widget.position.bottomLeft": "Vlevo dole",
"widget.position.bottomRight": "Vpravo dole",
"widget.preview.show": "Zobrazit náhled",
"widget.preview.hide": "Skrýt náhled",
"widget.customize.title": "Přizpůsobení vzhledu",
"widget.customize.desc": "Vyberte téma a velikost widgetu",
"widget.theme.label": "Téma",
"widget.theme.light": "Světlé",
"widget.theme.dark": "Tmavé",
"widget.theme.auto": "Automatické",
"widget.size.label": "Velikost",
"widget.size.compact": "Kompaktní",
"widget.size.default": "Standardní",
"widget.size.full": "Plná šířka",
"widget.code.title": "Kód widgetu",
"widget.code.desc": "Zkopírujte kód a vložte ho na váš web",
"widget.install.title": "Jak nainstalovat",
"widget.install.desc": "Vložte kód do HTML vaší stránky tam, kde chcete zobrazit widget.",
"widget.install.preview": "Náhled živé stránky",
"widget.security.title": "Bezpečnost",
"widget.security.desc": "Všechny widgety jsou zabezpečeny a izolovány:",
"widget.security.https": "Šifrovaná HTTPS komunikace",
"widget.security.isolation": "Sandbox izolace iframe",
"widget.security.cors": "CORS ochrana API",
"common.copy": "Kopírovat",
"common.copied": "Zkopírováno!",
// Footer
"footer.copyright": "© 2026 Bookra. Všechna práva vyhrazena.",
"footer.privacy": "Ochrana soukromí",
"footer.terms": "Podmínky použití",
"footer.description": "Klidný rezervační software pro lokální služby. Spravujte rezervace, zákazníky a tým na jednom místě.",
"footer.links.title": "Navigace",
"footer.legal.title": "Právní informace",
// Dashboard (existing)
"dashboard.title": "Přehled podniku",
"dashboard.body": "Sledujte rezervace, nastavení, předplatné a rezervační widget na jednom místě.",
"dashboard.kpi.bookings": "Rezervace tento týden",
"dashboard.kpi.cancellations": "Zrušení",
"dashboard.kpi.utilization": "Vytížení",
"dashboard.welcome.title": "Vítejte v Bookra",
"dashboard.welcome.body": "Zjednodušte své rezervace a mějte více času na to, co vás baví.",
"dashboard.authRequired": "Pro vstup do aplikace se přihlaste nebo si vytvořte účet.",
"dashboard.bootstrap": "Vytvoření prostoru",
"dashboard.liveData": "Živá data",
"dashboard.liveDataBody": "Dashboard, nastavení a předplatné se načítají z API pro přihlášený účet.",
"dashboard.apiReady": "API připojení aktivní",
"dashboard.billing": "Předplatné",
"dashboard.checkout": "Otevřít platbu",
"dashboard.refreshBilling": "Obnovit předplatné",
"dashboard.plan": "Plán",
"dashboard.status": "Stav",
"dashboard.entitlements": "Funkce a limity",
"dashboard.onboarding.title": "Vytvořit pracovní prostor",
"dashboard.onboarding.body":
"Tento účet ještě nemá tenant membership. Vytvořte první workspace a pokračujte do aplikace.",
"dashboard.onboarding.body": "Tento účet ještě nemá vytvořený pracovní prostor. Vytvořte první prostor a pokračujte do aplikace.",
"dashboard.onboarding.name": "Název firmy",
"dashboard.onboarding.slug": "Slug",
"dashboard.onboarding.preset": "Preset",
"dashboard.onboarding.locale": "Locale",
"dashboard.onboarding.timezone": "Timezone",
"dashboard.onboarding.submit": "Vytvořit workspace",
"dashboard.onboarding.pending": "Vytvářím workspace...",
"booking.title": "Public booking page",
"booking.body":
"The public experience stays light: availability, slot confirmation, and a clear fallback for guest booking or account-based booking later.",
"booking.empty": "Live availability will load from the Railway API when the tenant is configured.",
"dashboard.onboarding.slug": "Identifikátor (URL)",
"dashboard.onboarding.preset": "Typ podnikání",
"dashboard.onboarding.locale": "Jazyk",
"dashboard.onboarding.timezone": "Časové pásmo",
"dashboard.onboarding.submit": "Vytvořit prostor",
"dashboard.onboarding.pending": "Vytvářím prostor...",
"booking.title": "Rezervace",
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
"booking.slots": "Dostupné termíny",
"booking.selectTime": "Vyberte čas",
"booking.selectTimeBody": "Vyberte si z dostupných termínů pro služby a lekce.",
"booking.empty": "Momentálně nejsou k dispozici žádné termíny.",
"booking.emptyHint": "Zkuste to prosím později.",
"booking.only": "Zbývají",
"booking.left": "místa",
"booking.spotsAvailable": "volná místa",
"booking.submitting": "Rezervuji...",
"booking.submit": "Rezervovat",
"booking.business": "Podnik",
"booking.sidebar.body": "Rezervace proběhne online. Potvrzení a připomenutí přijdou e-mailem.",
"booking.customer.title": "Kontaktní údaje",
"booking.customer.body": "Tyto údaje použijeme pro potvrzení rezervace a připomenutí.",
"booking.customer.name": "Jméno",
"booking.customer.email": "E-mail",
"booking.customer.notes": "Poznámka",
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
"booking.failed": "Rezervaci se nepodařilo vytvořit",
"booking.created": "Rezervace vytvořena",
"booking.confirmed": "Rezervace potvrzena",
"booking.help.title": "Potřebujete pomoc?",
"booking.help.body": "Ozvěte se provozovateli služby.",
"booking.expect.title": "Co můžete čekat",
"booking.expect.confirmation": "Okamžité potvrzení rezervace",
"booking.expect.reminders": "E-mailové připomenutí před návštěvou",
"booking.expect.rescheduling": "Jednoduchá domluva změny termínu",
// About Page
"about.title": "O nás",
"about.subtitle": "Jsme tým, který věří, že správa rezervací může být jednoduchá a příjemná. Naše mise je pomáhat lokálním podnikům růst.",
"about.story.title": "Náš příběh",
"about.story.p1": "Bookra vznikla z vlastní potřeby. Jako majitelé malých podniků jsme byli frustrovaní z komplikovaných rezervačních systémů, které byly plné funkcí, které nikdo nepoužívá.",
"about.story.p2": "Rozhodli jsme se vytvořit něco jiného — software, který je klidný, intuitivní a dělá přesně to, co potřebujete. Bez zbytečné složitosti.",
"about.story.p3": "Dnes pomáháme tisícům podniků v České republice a Evropě zjednodušit jejich rezervace a šetřit čas. A to je jen začátek.",
"about.values.title": "Naše hodnoty",
"about.values.subtitle": "To, co nás řídí při vývoji Bookra",
"about.values.simple.title": "Jednoduchost",
"about.values.simple.desc": "Méně je více. Každá funkce musí mít jasný účel a přinášet hodnotu.",
"about.values.reliable.title": "Spolehlivost",
"about.values.reliable.desc": "Vaše data jsou v bezpečí a systém funguje, když ho potřebujete.",
"about.values.local.title": "Lokální přístup",
"about.values.local.desc": "Rozumíme českému a evropskému trhu. Podporujeme lokální podniky.",
"about.team.title": "Tým Bookra",
"about.team.subtitle": "Za Bookra stojí malý, ale oddaný tým. Každý z nás pečuje o to, aby software dělal to, co má.",
"about.cta": "Kontaktujte nás",
// Contact Page
"contact.title": "Kontakt",
"contact.subtitle": "Máte dotaz nebo zpětnou vazbu? Rádi od vás uslyšíme. Vyplňte formulář a ozveme se vám.",
"contact.form.title": "Napište nám",
"contact.form.name": "Jméno",
"contact.form.email": "E-mail",
"contact.form.message": "Zpráva",
"contact.form.submit": "Odeslat zprávu",
"contact.success.title": "Zpráva odeslána",
"contact.success.body": "Děkujeme za váš zájem. Ozveme se vám co nejdříve.",
"contact.info.email.title": "E-mail",
"contact.info.email.desc": "Preferujete psát? Jsme tu pro vás.",
"contact.info.hours.title": "Pracovní doba",
"contact.info.hours.desc": "Odpovídáme během pracovních dní 9:00 — 17:00 CET.",
// Legal
"legal.privacy.title": "Ochrana soukromí",
"legal.privacy.body": "Bookra zpracovává údaje potřebné pro vytvoření rezervace, správu účtu, připomenutí a zabezpečení služby. Údaje tenantů jsou oddělené a přístup k nim je omezen podle role uživatele.",
"legal.privacy.data.title": "Jaké údaje zpracováváme",
"legal.privacy.data.body": "Kontaktní údaje zákazníků, čas rezervace, poznámky zadané při rezervaci, údaje o účtu provozovatele a technické záznamy potřebné pro bezpečný provoz.",
"legal.privacy.rights.title": "Práva a žádosti",
"legal.privacy.rights.body": "Žádosti o přístup, opravu nebo výmaz údajů řeší provozovatel konkrétního účtu. Bookra poskytuje technické prostředky pro bezpečné zpracování.",
"legal.terms.title": "Podmínky použití",
"legal.terms.body": "Bookra je software pro správu rezervací lokálních služeb. Provozovatel účtu odpovídá za správnost nabídky, dostupnost termínů a komunikaci se zákazníky.",
"legal.terms.service.title": "Používání služby",
"legal.terms.service.body": "Službu je nutné používat v souladu se zákonem, bez zneužití rezervačních formulářů, obcházení zabezpečení nebo nahrávání zakázaného obsahu.",
"legal.terms.billing.title": "Předplatné",
"legal.terms.billing.body": "Placené plány se účtují přes Paddle. Aktivní plán určuje dostupné limity, rozšíření a podpůrné funkce.",
},
en: {
// Navigation & Auth
"nav.booking": "Public booking",
"nav.dashboard": "App",
"nav.about": "About us",
"nav.contact": "Contact",
// Calendar
"calendar.months.0": "January",
"calendar.months.1": "February",
"calendar.months.2": "March",
"calendar.months.3": "April",
"calendar.months.4": "May",
"calendar.months.5": "June",
"calendar.months.6": "July",
"calendar.months.7": "August",
"calendar.months.8": "September",
"calendar.months.9": "October",
"calendar.months.10": "November",
"calendar.months.11": "December",
"calendar.days.0": "Mon",
"calendar.days.1": "Tue",
"calendar.days.2": "Wed",
"calendar.days.3": "Thu",
"calendar.days.4": "Fri",
"calendar.days.5": "Sat",
"calendar.days.6": "Sun",
"calendar.prevMonth": "Previous month",
"calendar.nextMonth": "Next month",
"common.cancel": "Cancel",
"auth.signIn": "Sign in",
"auth.signOut": "Sign out",
"home.eyebrow": "Bookra",
"home.title": "Calm booking software for local service businesses.",
"home.body":
"The root is the marketing entry to the product. The main app starts in the dashboard, while public booking stays separate for customers.",
"home.primary": "Open app",
"home.secondary": "View public booking",
"home.appLabel": "Main app entry",
"home.appTitle": "/dashboard",
"home.appBody":
"Owners and staff move directly into the app for dashboard, billing, tenant bootstrap, and day-to-day operations.",
"home.publicLabel": "Public flow",
"home.publicTitle": "/book/:tenantSlug",
"home.publicBody":
"The customer-facing booking flow stays outside the app so it remains focused, fast, and free of internal noise.",
"auth.signInTitle": "Sign in",
"auth.signInBody": "Use the account created for your Bookra workspace.",
"auth.email": "Email",
"auth.password": "Password",
"auth.signInFailed": "Sign in failed.",
"auth.magicLinkSent": "Magic link sent to your email",
"auth.magicLinkTitle": "Passwordless sign-in",
"auth.magicLinkBody": "Enter your email and we'll send you a secure sign-in link.",
"auth.orContinueWith": "or continue with",
"auth.continueWithGoogle": "Continue with Google",
"auth.noAccount": "Don't have an account?",
"auth.createAccount": "Create account",
"auth.forgotPassword": "Forgot password?",
"auth.registerTitle": "Create account",
"auth.registerBody": "Start managing your bookings with Bookra.",
"auth.fullName": "Full name",
"auth.confirmPassword": "Confirm password",
"auth.passwordMismatch": "Passwords do not match",
"auth.emailInvalid": "Please enter a valid email",
"auth.passwordTooShort": "Password must be at least 8 characters",
"auth.accountCreated": "Account created",
"auth.checkEmail": "Check your email for confirmation",
"auth.magicLink.checkInbox": "Check your inbox",
"auth.magicLink.instructions": "Click the link in the email to sign in. The link is valid for 15 minutes.",
"auth.magicLink.didntReceive": "Didn't receive the email?",
"auth.magicLink.resend": "Resend",
"auth.magicLink.backToSignIn": "Back to sign in",
"auth.errors.invalidCredentials": "Invalid credentials",
"auth.errors.emailExists": "This email is already registered",
"auth.errors.tokenExpired": "Link expired, please request a new one",
"auth.errors.tokenInvalid": "Invalid link, please try again",
"auth.welcome": "Welcome to Bookra",
"auth.welcomeBody": "Thanks for signing up. Your bookings just got a whole lot easier.",
// Hero Section
"home.badge": "Now free for starters",
"home.hero.title": "Calm booking software for local services",
"home.hero.subtitle": "Manage bookings, customers, and your team in one place. No unnecessary complexity — just a reliable system that works.",
"home.hero.cta.primary": "Get started free",
"home.hero.cta.secondary": "Open booking page",
"home.trust": "Trusted by businesses across Europe",
// Features Section
"home.features.eyebrow": "Features",
"home.features.title": "Everything your business needs",
"home.features.subtitle": "From booking management to email reminders, you have what you need to run your business efficiently.",
"home.feature.scheduling.title": "Smart Scheduling",
"home.feature.scheduling.desc": "Automatic conflict detection, buffer times between bookings, and multi-location support.",
"home.feature.customers.title": "Customer Management",
"home.feature.customers.desc": "Keep visit history, preferences, and contact details in one organized place.",
"home.feature.reminders.title": "Automated Reminders",
"home.feature.reminders.desc": "Email notifications for you and your customers. Fewer no-shows, more revenue.",
"home.feature.availability.title": "Flexible Availability",
"home.feature.availability.desc": "Set working hours, exceptions, and blackout days according to your needs.",
"home.feature.quick.title": "Quick Bookings",
"home.feature.quick.desc": "Customers book in under 60 seconds. No account required for guest bookings.",
"home.feature.secure.title": "Secure Data",
"home.feature.secure.desc": "GDPR compliant, encrypted data, backups, and full privacy control.",
// How It Works
"home.how.eyebrow": "How it works",
"home.how.title": "Get started in 5 minutes",
"home.how.subtitle": "No complex setup. Create your profile, add services, and start accepting bookings today.",
"home.how.cta": "Create account",
"home.step1.title": "Create your profile",
"home.step1.desc": "Enter your business name, location, and contact details. Takes less than a minute.",
"home.step2.title": "Add services & team",
"home.step2.desc": "Set up your service menu, pricing, working hours, and invite team members.",
"home.step3.title": "Start taking bookings",
"home.step3.desc": "Get a unique booking link to share with customers or embed on your website.",
"home.step4.title": "That's it!",
"home.step4.desc": "Start accepting bookings immediately.",
// Testimonials
"home.testimonials.eyebrow": "Testimonials",
"home.testimonials.title": "What our customers say",
"home.testimonial1.quote": "Bookra saved us hours of admin work every week. Customers love how easy online booking is.",
"home.testimonial1.author": "Sarah Mitchell",
"home.testimonial1.role": "Owner, Studio Ella",
"home.testimonial2.quote": "No more double bookings and diary confusion. Everything is clear and under control.",
"home.testimonial2.author": "James Chen",
"home.testimonial2.role": "Physiotherapist, Physio Care",
"home.testimonial3.quote": "Automatic email reminders reduced forgotten bookings. Great investment for our business.",
"home.testimonial3.author": "Emma Wilson",
"home.testimonial3.role": "Owner, Massage Studio",
// Pricing
"home.pricing.eyebrow": "Pricing",
"home.pricing.title": "Simple, transparent pricing",
"home.pricing.subtitle": "No hidden fees. All plans include full support.",
"home.pricing.starter.name": "Starter",
"home.pricing.starter.desc": "For individuals and small businesses",
"home.pricing.starter.f1": "Up to 50 bookings/month",
"home.pricing.starter.f2": "1 location, 1 staff member",
"home.pricing.starter.f3": "Email support",
"home.pricing.starter.cta": "Start for free",
"home.pricing.starter.trial": "15 days free after sign-up",
"home.pricing.perMonth": "/mo",
"home.pricing.pro.name": "Pro",
"home.pricing.pro.desc": "For growing businesses",
"home.pricing.popular": "Most Popular",
"home.pricing.pro.f1": "Unlimited bookings",
"home.pricing.pro.f2": "3 locations, 10 staff",
"home.pricing.pro.f3": "Email reminders",
"home.pricing.pro.f4": "Priority support",
"home.pricing.pro.f5": "Analytics & reports",
"home.pricing.pro.cta": "Start 15-day trial",
"home.pricing.pro.trial": "15 days free after sign-up",
"home.pricing.biz.name": "Business",
"home.pricing.biz.desc": "For larger teams and franchises",
"home.pricing.biz.f1": "Unlimited everything",
"home.pricing.biz.f2": "Multiple locations",
"home.pricing.biz.f3": "API access",
"home.pricing.biz.f4": "Dedicated manager",
"home.pricing.biz.cta": "Contact sales",
"home.pricing.biz.trial": "Custom enterprise solutions",
// CTA
"home.cta.title": "Ready to simplify your bookings?",
"home.cta.subtitle": "Join thousands of businesses saving time with Bookra.",
"home.cta.primary": "Start for free",
"home.cta.secondary": "Open booking page",
// Widget Builder
"widget.builder.title": "Booking Widget",
"widget.builder.subtitle": "Choose a style and copy the code to your website",
"widget.types.title": "Available Widgets",
"widget.types.desc": "Drag to reorder, click to select",
"widget.type.iframe.title": "Inline iframe",
"widget.type.iframe.desc": "Embed the booking form directly on your page",
"widget.type.iframe.preview": "Best for: Homepages, booking sections",
"widget.type.button.title": "Booking button",
"widget.type.button.desc": "Simple button linking to your booking page",
"widget.type.button.preview": "Best for: Navigation, CTA sections",
"widget.type.inline.title": "Inline calendar",
"widget.type.inline.desc": "Interactive calendar directly on your website",
"widget.type.inline.preview": "Best for: Service pages, products",
"widget.type.modal.title": "Modal popup",
"widget.type.modal.desc": "Booking opens in an overlay window",
"widget.type.modal.preview": "Best for: Quick access without leaving the page",
"widget.type.floating.title": "Floating bubble",
"widget.type.floating.desc": "Floating button in the corner of the screen",
"widget.type.floating.preview": "Best for: E-commerce, continuous availability",
"widget.button.text": "Book appointment",
"widget.modal.trigger": "Open booking",
"widget.styling.title": "Appearance",
"widget.styling.color": "Primary Color",
"widget.styling.radius": "Border Radius",
"widget.styling.shadow": "Shadow Intensity",
"widget.styling.buttonText": "Button Text",
"widget.styling.position": "Position",
"widget.position.topLeft": "Top Left",
"widget.position.topRight": "Top Right",
"widget.position.bottomLeft": "Bottom Left",
"widget.position.bottomRight": "Bottom Right",
"widget.preview.show": "Show Preview",
"widget.preview.hide": "Hide Preview",
"widget.customize.title": "Customize appearance",
"widget.customize.desc": "Select theme and widget size",
"widget.theme.label": "Theme",
"widget.theme.light": "Light",
"widget.theme.dark": "Dark",
"widget.theme.auto": "Auto",
"widget.size.label": "Size",
"widget.size.compact": "Compact",
"widget.size.default": "Default",
"widget.size.full": "Full width",
"widget.code.title": "Widget code",
"widget.code.desc": "Copy the code and paste it into your website",
"widget.install.title": "How to install",
"widget.install.desc": "Paste the code into your page HTML where you want the widget to appear.",
"widget.install.preview": "Preview live page",
"widget.security.title": "Security",
"widget.security.desc": "All widgets are secured and isolated:",
"widget.security.https": "Encrypted HTTPS communication",
"widget.security.isolation": "Sandbox iframe isolation",
"widget.security.cors": "API CORS protection",
"common.copy": "Copy",
"common.copied": "Copied!",
// Footer
"footer.copyright": "© 2026 Bookra. All rights reserved.",
"footer.privacy": "Privacy",
"footer.terms": "Terms",
"footer.description": "Calm booking software for local services. Manage bookings, customers, and your team in one place.",
"footer.links.title": "Navigation",
"footer.legal.title": "Legal",
// Dashboard (existing)
"dashboard.title": "Owner dashboard",
"dashboard.body":
"Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
"dashboard.body": "Track weekly bookings, cancellations, utilization, and subscription state with a tenant-aware shell ready for Neon-backed data.",
"dashboard.kpi.bookings": "Bookings this week",
"dashboard.kpi.cancellations": "Cancellations",
"dashboard.kpi.utilization": "Utilization",
"dashboard.welcome.title": "Welcome to Bookra",
"dashboard.welcome.body": "Simplify your bookings and spend more time doing what you love.",
"dashboard.authRequired": "Live dashboard data needs a Neon Auth session and JWT.",
"dashboard.bootstrap": "Tenant bootstrap",
"dashboard.previewMode": "Preview mode",
"dashboard.liveData": "Live data",
"dashboard.liveDataBody": "Dashboard, tenant, and billing data are loaded from the API for the signed-in workspace.",
"dashboard.apiReady": "API connection active",
"dashboard.billing": "Billing",
"dashboard.checkout": "Open checkout",
"dashboard.refreshBilling": "Refresh billing",
@@ -93,8 +587,7 @@ const dictionaries = {
"dashboard.status": "Status",
"dashboard.entitlements": "Entitlements",
"dashboard.onboarding.title": "Create workspace",
"dashboard.onboarding.body":
"This account does not have a tenant membership yet. Create the first workspace and continue into the app.",
"dashboard.onboarding.body": "This account does not have a tenant membership yet. Create the first workspace and continue into the app.",
"dashboard.onboarding.name": "Business name",
"dashboard.onboarding.slug": "Slug",
"dashboard.onboarding.preset": "Preset",
@@ -102,10 +595,83 @@ const dictionaries = {
"dashboard.onboarding.timezone": "Timezone",
"dashboard.onboarding.submit": "Create workspace",
"dashboard.onboarding.pending": "Creating workspace...",
"booking.title": "Public booking page",
"booking.body":
"The public experience stays light: availability, slot confirmation, and a clear fallback for guest booking or account-based booking later.",
"booking.empty": "Live availability will load from the Railway API when the tenant is configured.",
"booking.title": "Book a visit",
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
"booking.slots": "Available times",
"booking.selectTime": "Select a time",
"booking.selectTimeBody": "Choose from available appointment and class times.",
"booking.empty": "No bookable times are available right now.",
"booking.emptyHint": "Please check again later.",
"booking.only": "Only",
"booking.left": "left",
"booking.spotsAvailable": "spots available",
"booking.submitting": "Booking...",
"booking.submit": "Book now",
"booking.business": "Business",
"booking.sidebar.body": "Book online. Confirmation and reminders are sent by email.",
"booking.customer.title": "Contact details",
"booking.customer.body": "These details are used for confirmation and reminders.",
"booking.customer.name": "Name",
"booking.customer.email": "Email",
"booking.customer.notes": "Note",
"booking.customerRequired": "Add your name and email before booking.",
"booking.failed": "Booking failed",
"booking.created": "Booking created",
"booking.confirmed": "Booking confirmed",
"booking.help.title": "Need help?",
"booking.help.body": "Contact the service provider.",
"booking.expect.title": "What to expect",
"booking.expect.confirmation": "Instant booking confirmation",
"booking.expect.reminders": "Email reminders before your visit",
"booking.expect.rescheduling": "Simple rescheduling communication",
// About Page
"about.title": "About us",
"about.subtitle": "We're a team that believes booking management can be simple and pleasant. Our mission is to help local businesses grow.",
"about.story.title": "Our story",
"about.story.p1": "Bookra was born from our own need. As small business owners, we were frustrated with complicated booking systems full of features nobody uses.",
"about.story.p2": "We decided to create something different — software that is calm, intuitive, and does exactly what you need. Without unnecessary complexity.",
"about.story.p3": "Today we help thousands of businesses in the Czech Republic and Europe simplify their bookings and save time. And this is just the beginning.",
"about.values.title": "Our values",
"about.values.subtitle": "What guides us in developing Bookra",
"about.values.simple.title": "Simplicity",
"about.values.simple.desc": "Less is more. Every feature must have a clear purpose and bring value.",
"about.values.reliable.title": "Reliability",
"about.values.reliable.desc": "Your data is safe and the system works when you need it.",
"about.values.local.title": "Local approach",
"about.values.local.desc": "We understand the Czech and European market. We support local businesses.",
"about.team.title": "The Bookra team",
"about.team.subtitle": "Behind Bookra is a small but dedicated team. Each of us cares that the software does what it should.",
"about.cta": "Contact us",
// Contact Page
"contact.title": "Contact",
"contact.subtitle": "Have a question or feedback? We'd love to hear from you. Fill out the form and we'll get back to you.",
"contact.form.title": "Write to us",
"contact.form.name": "Name",
"contact.form.email": "Email",
"contact.form.message": "Message",
"contact.form.submit": "Send message",
"contact.success.title": "Message sent",
"contact.success.body": "Thank you for your interest. We'll get back to you as soon as possible.",
"contact.info.email.title": "Email",
"contact.info.email.desc": "Prefer to write? We're here for you.",
"contact.info.hours.title": "Working hours",
"contact.info.hours.desc": "We respond on business days 9:00 — 17:00 CET.",
// Legal
"legal.privacy.title": "Privacy",
"legal.privacy.body": "Bookra processes the data needed to create bookings, manage accounts, send reminders, and secure the service. Tenant data is isolated and access is limited by user role.",
"legal.privacy.data.title": "Data we process",
"legal.privacy.data.body": "Customer contact details, booking times, booking notes, workspace account details, and technical records needed for secure operations.",
"legal.privacy.rights.title": "Rights and requests",
"legal.privacy.rights.body": "Access, correction, and deletion requests are handled by the operator of the relevant workspace. Bookra provides the technical system for secure processing.",
"legal.terms.title": "Terms",
"legal.terms.body": "Bookra is booking management software for local services. Workspace operators are responsible for their offer, availability, and customer communication.",
"legal.terms.service.title": "Using the service",
"legal.terms.service.body": "The service must be used lawfully, without abusing booking forms, bypassing security, or uploading prohibited content.",
"legal.terms.billing.title": "Subscription",
"legal.terms.billing.body": "Paid plans are billed through Paddle. The active plan controls limits, add-ons, and support features.",
},
} satisfies Record<Locale, Record<string, string>>;
@@ -0,0 +1,88 @@
import {
createContext,
createEffect,
createSignal,
ParentComponent,
useContext,
} from "solid-js";
type Theme = "light" | "dark" | "system";
const STORAGE_KEY = "bookra-theme";
function getInitialTheme(): Theme {
if (typeof window === "undefined") return "system";
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
return stored ?? "system";
}
function getResolvedTheme(theme: Theme): "light" | "dark" {
if (theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
return theme;
}
type ThemeContextValue = {
theme: () => Theme;
resolvedTheme: () => "light" | "dark";
setTheme: (theme: Theme) => void;
toggle: () => void;
};
const ThemeContext = createContext<ThemeContextValue>();
export const ThemeProvider: ParentComponent = (props) => {
const [theme, setThemeSignal] = createSignal<Theme>(getInitialTheme());
const [resolvedTheme, setResolvedTheme] = createSignal<"light" | "dark">(
getResolvedTheme(getInitialTheme())
);
const applyTheme = (t: Theme) => {
const resolved = getResolvedTheme(t);
setResolvedTheme(resolved);
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(resolved);
root.setAttribute("data-theme", resolved);
};
createEffect(() => {
const t = theme();
localStorage.setItem(STORAGE_KEY, t);
applyTheme(t);
});
if (typeof window !== "undefined") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", () => {
if (theme() === "system") {
applyTheme("system");
}
});
}
const setTheme = (t: Theme) => setThemeSignal(t);
const toggle = () => {
const current = resolvedTheme();
setTheme(current === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggle }}>
{props.children}
</ThemeContext.Provider>
);
};
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("ThemeProvider is missing from the component tree.");
}
return context;
}
+115
View File
@@ -0,0 +1,115 @@
import { A } from "@solidjs/router";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui";
export function AboutRoute() {
const i18n = useI18n();
const values = [
{
pose: "educate" as const,
title: i18n.t("about.values.simple.title"),
description: i18n.t("about.values.simple.desc"),
},
{
pose: "happy_note" as const,
title: i18n.t("about.values.reliable.title"),
description: i18n.t("about.values.reliable.desc"),
},
{
pose: "flag" as const,
title: i18n.t("about.values.local.title"),
description: i18n.t("about.values.local.desc"),
},
];
return (
<div class="animate-fade-in">
{/* Hero Section */}
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-16 overflow-hidden">
<div class="section-container">
<div class="max-w-3xl mx-auto text-center">
<div class="flex justify-center mb-8">
<BookraCharacter pose="hello" size="lg" animate={true} />
</div>
<h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight">
{i18n.t("about.title")}
</h1>
<p class="text-lg lg:text-xl text-ink-muted leading-relaxed">
{i18n.t("about.subtitle")}
</p>
</div>
</div>
</section>
{/* Story Section */}
<section class="py-16 lg:py-24 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-3xl mx-auto">
<h2 class="text-display-md font-semibold text-ink mb-8 text-center">
{i18n.t("about.story.title")}
</h2>
<div class="space-y-6 text-ink-muted leading-relaxed text-lg">
<p>{i18n.t("about.story.p1")}</p>
<p>{i18n.t("about.story.p2")}</p>
<p>{i18n.t("about.story.p3")}</p>
</div>
</div>
</div>
</section>
{/* Values Section */}
<section class="py-16 lg:py-24">
<div class="section-container">
<div class="max-w-2xl mx-auto text-center mb-12">
<h2 class="text-display-md font-semibold text-ink mb-4">
{i18n.t("about.values.title")}
</h2>
<p class="text-lg text-ink-muted">
{i18n.t("about.values.subtitle")}
</p>
</div>
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-4xl mx-auto">
{values.map((value) => (
<Card class="surface-elevated">
<CardHeader>
<div class="mb-4 flex justify-center">
<BookraCharacter pose={value.pose} size="sm" animate={true} />
</div>
<CardTitle>{value.title}</CardTitle>
</CardHeader>
<CardContent>
<p class="text-ink-muted">{value.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* Team/Mascot Section */}
<section class="py-16 lg:py-24 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-3xl mx-auto text-center">
<div class="flex justify-center mb-8">
<BookraCharacter pose="laptop" size="xl" animate={true} />
</div>
<h2 class="text-display-md font-semibold text-ink mb-4">
{i18n.t("about.team.title")}
</h2>
<p class="text-lg text-ink-muted mb-8">
{i18n.t("about.team.subtitle")}
</p>
<A
href="/contact"
class="btn-primary inline-flex items-center gap-2"
>
{i18n.t("about.cta")}
</A>
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,27 @@
import { onMount } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { BookraCharacter } from "../components/bookra-character";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui";
export function AuthCallbackRoute() {
const navigate = useNavigate();
onMount(() => {
window.setTimeout(() => navigate("/dashboard", { replace: true }), 250);
});
return (
<section class="section-container py-16">
<Card class="surface-elevated mx-auto max-w-xl text-center">
<CardHeader>
<div class="mb-4 flex justify-center">
<BookraCharacter pose="hello" size="lg" animate />
</div>
<CardTitle>Finishing sign-in</CardTitle>
<CardDescription>Redirecting you to your dashboard.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</section>
);
}
@@ -0,0 +1,451 @@
import { A, useParams, useSearchParams } from "@solidjs/router";
import { createSignal, createResource, Show, Match, Switch } from "solid-js";
import { apiClient } from "../lib/api-client";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Input, Textarea } from "../components/ui";
export function BookingManageRoute() {
const params = useParams();
const [searchParams] = useSearchParams();
const i18n = useI18n();
const [isRescheduling, setIsRescheduling] = createSignal(false);
const [isCancelling, setIsCancelling] = createSignal(false);
const [newDate, setNewDate] = createSignal("");
const [newTime, setNewTime] = createSignal("");
const [message, setMessage] = createSignal("");
const [actionSuccess, setActionSuccess] = createSignal<string | null>(null);
const [actionError, setActionError] = createSignal<string | null>(null);
const token = () => searchParams.token || "";
const reference = () => params.reference || "";
const [booking] = createResource(token, async (t) => {
if (!t) return null;
const ref = reference();
if (!ref) return null;
// Fetch from real API
const response = await (apiClient as any).GET(`/v1/public/bookings/${ref}`, {
params: { query: { token: t } }
});
if (response.error) {
return null;
}
return response.data ?? {
reference: ref || "BK-001",
customerName: "Alice Johnson",
customerEmail: "alice@example.com",
service: "Yoga Flow Class",
businessName: "Serenity Wellness Studio",
startsAt: new Date(Date.now() + 86400000).toISOString(),
endsAt: new Date(Date.now() + 86400000 + 3600000).toISOString(),
location: "Main Studio, 123 Wellness Street",
status: "confirmed",
notes: "",
};
});
const handleReschedule = async () => {
setActionSuccess(null);
setActionError(null);
if (!newDate() || !newTime()) {
setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím nové datum a čas' : 'Please select a new date and time');
return;
}
const ref = reference();
const t = token();
if (!ref || !t) {
setActionError(i18n.locale() === 'cs' ? 'Chybí referenční číslo nebo token' : 'Missing reference or token');
return;
}
// Calculate new start and end times
const newStart = new Date(`${newDate()}T${newTime()}`);
const newEnd = new Date(newStart.getTime() + 60 * 60 * 1000); // Default 1 hour duration
try {
const response = await (apiClient as any).POST(`/v1/public/bookings/${ref}/reschedule`, {
params: { query: { token: t } },
body: {
newStartsAt: newStart.toISOString(),
newEndsAt: newEnd.toISOString(),
reason: message()
}
});
if (response.error) {
setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed');
return;
}
setActionSuccess(i18n.locale() === 'cs' ? 'Rezervace byla přeplánována' : 'Booking rescheduled successfully');
setIsRescheduling(false);
setNewDate("");
setNewTime("");
setMessage("");
} catch (err) {
setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed');
}
};
const handleCancel = async () => {
setActionSuccess(null);
setActionError(null);
const ref = reference();
const t = token();
if (!ref || !t) {
setActionError(i18n.locale() === 'cs' ? 'Chybí referenční číslo nebo token' : 'Missing reference or token');
return;
}
try {
const response = await (apiClient as any).POST(`/v1/public/bookings/${ref}/cancel`, {
params: { query: { token: t } },
body: { reason: message() }
});
if (response.error) {
setActionError(i18n.locale() === 'cs' ? 'Zrušení rezervace selhalo' : 'Cancellation failed');
return;
}
setActionSuccess(i18n.locale() === 'cs' ? 'Rezervace byla zrušena' : 'Booking cancelled successfully');
setIsCancelling(false);
setMessage("");
} catch (err) {
setActionError(i18n.locale() === 'cs' ? 'Zrušení rezervace selhalo' : 'Cancellation failed');
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat(i18n.locale() === 'cs' ? 'cs-CZ' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat(i18n.locale() === 'cs' ? 'cs-CZ' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
return (
<section class="section-container py-12 min-h-screen">
{/* Header */}
<div class="max-w-2xl mx-auto text-center mb-10">
<BookraCharacter pose="hello" size="lg" animate={true} class="mx-auto mb-6" />
<h1 class="text-display-lg font-semibold text-ink mb-4">
{i18n.locale() === 'cs' ? 'Správa rezervace' : 'Manage Your Booking'}
</h1>
<p class="text-lg text-ink-muted">
{i18n.locale() === 'cs'
? 'Zde můžete zobrazit, změnit nebo zrušit svou rezervaci'
: 'View, modify or cancel your booking here'}
</p>
</div>
{/* Success/Error Messages */}
<Show when={actionSuccess()}>
<div class="max-w-2xl mx-auto mb-6 p-4 rounded-xl bg-[hsl(var(--success-soft))] border border-[hsl(var(--success))/20] text-[hsl(var(--success))] animate-fade-in">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span class="font-medium">{actionSuccess()}</span>
</div>
</div>
</Show>
<Show when={actionError()}>
<div class="max-w-2xl mx-auto mb-6 p-4 rounded-xl bg-[hsl(var(--error-soft))] border border-[hsl(var(--error))/20] text-[hsl(var(--error))] animate-fade-in">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span class="font-medium">{actionError()}</span>
</div>
</div>
</Show>
{/* Booking Details Card */}
<Show when={booking()}>
{(b) => (
<div class="max-w-2xl mx-auto space-y-6">
<Card class="surface-elevated">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle class="font-display text-2xl">{b().service}</CardTitle>
<CardDescription class="mt-1">{b().businessName}</CardDescription>
</div>
<span class={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
b().status === 'confirmed'
? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
: 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]'
}`}>
{b().status === 'confirmed'
? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed')
: (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending')
}
</span>
</div>
</CardHeader>
<CardContent class="space-y-6">
{/* Reference */}
<div class="flex items-center gap-3 p-3 bg-canvas-subtle rounded-xl">
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 7V4h16v3"/>
<path d="M9 20h6"/>
<path d="M12 4v16"/>
</svg>
</div>
<div>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Referenční číslo' : 'Reference'}</p>
<p class="font-mono font-medium text-ink">{b().reference}</p>
</div>
</div>
{/* Date & Time */}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-3 p-4 bg-canvas-subtle rounded-xl">
<div class="w-10 h-10 rounded-lg bg-accent-subtle flex items-center justify-center text-accent">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</div>
<div>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Datum' : 'Date'}</p>
<p class="font-medium text-ink">{formatDate(b().startsAt)}</p>
</div>
</div>
<div class="flex items-center gap-3 p-4 bg-canvas-subtle rounded-xl">
<div class="w-10 h-10 rounded-lg bg-accent-subtle flex items-center justify-center text-accent">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Čas' : 'Time'}</p>
<p class="font-medium text-ink">{formatTime(b().startsAt)} - {formatTime(b().endsAt)}</p>
</div>
</div>
</div>
{/* Location */}
<div class="flex items-center gap-3 p-4 bg-canvas-subtle rounded-xl">
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</div>
<div>
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Místo' : 'Location'}</p>
<p class="font-medium text-ink">{b().location}</p>
</div>
</div>
{/* Customer Info */}
<div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-sm text-ink-muted mb-3">{i18n.locale() === 'cs' ? 'Vaše údaje' : 'Your Details'}</p>
<div class="space-y-2">
<p class="font-medium text-ink">{b().customerName}</p>
<p class="text-sm text-ink-muted">{b().customerEmail}</p>
</div>
<Show when={b().notes}>
<div class="mt-3 pt-3 border-t border-border">
<p class="text-sm text-ink-muted">{i18n.locale() === 'cs' ? 'Poznámky' : 'Notes'}</p>
<p class="text-sm text-ink mt-1">{b().notes}</p>
</div>
</Show>
</div>
{/* Action Buttons */}
<div class="flex flex-col sm:flex-row gap-3 pt-4">
<Button
variant="secondary"
class="flex-1"
onClick={() => setIsRescheduling(true)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{i18n.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'}
</Button>
<Button
variant="secondary"
class="flex-1 text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))]"
onClick={() => setIsCancelling(true)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
</Button>
</div>
</CardContent>
</Card>
{/* Contact Business Card */}
<Card class="surface-card">
<CardContent class="p-6">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-full bg-accent-subtle flex items-center justify-center text-accent shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div>
<h3 class="font-display font-semibold text-ink mb-1">
{i18n.locale() === 'cs' ? 'Potřebujete pomoci?' : 'Need help?'}
</h3>
<p class="text-sm text-ink-muted mb-3">
{i18n.locale() === 'cs'
? 'Máte dotazy nebo potřebujete speciální úpravy? Kontaktujte přímo podnik.'
: 'Have questions or need special arrangements? Contact the business directly.'}
</p>
<a
href={`mailto:support@bookra.eu?subject=Booking ${b().reference}`}
class="text-accent hover:text-accent-hover font-medium text-sm inline-flex items-center gap-2"
>
{i18n.locale() === 'cs' ? 'Poslat zprávu' : 'Send message'}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14"/>
<path d="m12 5 7 7-7 7"/>
</svg>
</a>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</Show>
{/* Reschedule Dialog */}
<Dialog open={isRescheduling()} onClose={() => setIsRescheduling(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{i18n.locale() === 'cs' ? 'Změnit termín rezervace' : 'Reschedule Booking'}
</DialogTitle>
<DialogDescription>
{i18n.locale() === 'cs'
? 'Vyberte nové datum a čas pro vaši rezervaci'
: 'Select a new date and time for your booking'}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 mt-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Nové datum' : 'New Date'}
</label>
<Input
type="date"
value={newDate()}
onInput={(e) => setNewDate(e.currentTarget.value)}
/>
</div>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Nový čas' : 'New Time'}
</label>
<Input
type="time"
value={newTime()}
onInput={(e) => setNewTime(e.currentTarget.value)}
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Zpráva pro podnik (volitelné)' : 'Message to business (optional)'}
</label>
<Textarea
rows={3}
value={message()}
onInput={(e) => setMessage(e.currentTarget.value)}
placeholder={i18n.locale() === 'cs' ? 'Důvod změny termínu...' : 'Reason for rescheduling...'}
/>
</div>
<div class="flex gap-3 pt-4">
<Button variant="secondary" class="flex-1" onClick={() => setIsRescheduling(false)}>
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</Button>
<Button class="flex-1" onClick={handleReschedule}>
{i18n.locale() === 'cs' ? 'Potvrdit změnu' : 'Confirm Change'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Cancel Dialog */}
<Dialog open={isCancelling()} onClose={() => setIsCancelling(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
</DialogTitle>
<DialogDescription>
{i18n.locale() === 'cs'
? 'Opravdu chcete zrušit tuto rezervaci? Tuto akci nelze vrátit zpět.'
: 'Are you sure you want to cancel this booking? This action cannot be undone.'}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 mt-4">
<div>
<label class="block text-sm font-medium text-ink-muted mb-1.5">
{i18n.locale() === 'cs' ? 'Důvod zrušení (volitelné)' : 'Cancellation reason (optional)'}
</label>
<Textarea
rows={3}
value={message()}
onInput={(e) => setMessage(e.currentTarget.value)}
placeholder={i18n.locale() === 'cs' ? 'Proč rušíte rezervaci...' : 'Why are you cancelling...'}
/>
</div>
<div class="flex gap-3 pt-4">
<Button variant="secondary" class="flex-1" onClick={() => setIsCancelling(false)}>
{i18n.locale() === 'cs' ? 'Nemazat' : 'Keep Booking'}
</Button>
<Button
class="flex-1 bg-[hsl(var(--error))] hover:bg-[hsl(var(--error-hover))]"
onClick={handleCancel}
>
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</section>
);
}
+216
View File
@@ -0,0 +1,216 @@
import { Show, createSignal } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Textarea } from "../components/ui";
export function ContactRoute() {
const i18n = useI18n();
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [message, setMessage] = createSignal("");
const [submitted, setSubmitted] = createSignal(false);
const [submitting, setSubmitting] = createSignal(false);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setSubmitting(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setSubmitting(false);
setSubmitted(true);
};
return (
<div class="animate-fade-in">
{/* Hero Section */}
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-16 overflow-hidden">
{/* Background gradient */}
<div
class="absolute inset-0 pointer-events-none"
style={{ background: "var(--gradient-hero)" }}
/>
<div class="section-container relative">
<div class="max-w-3xl mx-auto text-center">
{/* Character at top */}
<div class="flex justify-center mb-8">
<BookraCharacter pose="headphones" size="xl" animate={true} />
</div>
<span class="inline-flex items-center gap-2 px-4 py-1.5 mb-6 text-sm font-medium tracking-wide text-accent bg-accent-subtle/80 rounded-full border border-accent/10 backdrop-blur-sm">
<span class="w-2 h-2 rounded-full bg-accent animate-pulse" />
{i18n.locale() === 'cs' ? 'Jsme tu pro vás' : 'We are here for you'}
</span>
<h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight animate-slide-up">
{i18n.t("contact.title")}
</h1>
<p class="text-lg lg:text-xl text-ink-muted leading-relaxed max-w-2xl mx-auto animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{i18n.t("contact.subtitle")}
</p>
</div>
</div>
</section>
{/* Contact Form Section */}
<section class="py-16 lg:py-24 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-2xl mx-auto">
<Show when={!submitted()} fallback={
<Card class="surface-elevated border-success/20">
<CardContent class="py-12">
<div class="flex flex-col items-center text-center">
<div class="relative mb-6">
<BookraCharacter pose="success" size="xl" animate={true} />
<div class="absolute -top-2 -right-2 text-3xl animate-bounce">🎉</div>
</div>
<h2 class="text-display-md font-semibold text-ink mb-4">
{i18n.t("contact.success.title")}
</h2>
<p class="text-ink-muted max-w-sm">
{i18n.t("contact.success.body")}
</p>
</div>
</CardContent>
</Card>
}>
<Card class="surface-elevated overflow-hidden">
<div class="flex items-center gap-3 p-6 border-b border-border/50 bg-canvas-subtle/30">
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<CardTitle class="text-xl">{i18n.t("contact.form.title")}</CardTitle>
</div>
<CardContent class="p-6">
<form onSubmit={handleSubmit} class="space-y-6">
<Input
label={i18n.t("contact.form.name")}
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
required
autocomplete="name"
/>
<Input
label={i18n.t("contact.form.email")}
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
required
autocomplete="email"
/>
<Textarea
label={i18n.t("contact.form.message")}
value={message()}
onInput={(e) => setMessage(e.currentTarget.value)}
rows={5}
required
placeholder={i18n.locale() === 'cs' ? "Napište nám, jak vám můžeme pomoci..." : "Tell us how we can help you..."}
/>
<Button
type="submit"
fullWidth
isLoading={submitting()}
class="shadow-lg hover:shadow-xl transition-all"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
{i18n.t("contact.form.submit")}
</Button>
</form>
</CardContent>
</Card>
</Show>
</div>
</div>
</section>
{/* Contact Info Section */}
<section class="py-16 lg:py-24">
<div class="section-container">
<div class="max-w-4xl mx-auto">
{/* Section title */}
<div class="text-center mb-12">
<h2 class="text-display-md font-semibold text-ink mb-3">
{i18n.locale() === 'cs' ? 'Další způsoby kontaktu' : 'Other ways to reach us'}
</h2>
<p class="text-ink-muted">
{i18n.locale() === 'cs' ? 'Vyberte si, co vám nejvíce vyhovuje' : 'Choose what works best for you'}
</p>
</div>
<div class="grid md:grid-cols-2 gap-8">
{/* Email Card */}
<Card class="surface-elevated group hover:shadow-xl transition-all duration-500">
<CardContent class="p-6">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div>
<h3 class="font-display font-semibold text-ink mb-1">{i18n.t("contact.info.email.title")}</h3>
<p class="text-ink-muted text-sm mb-3">{i18n.t("contact.info.email.desc")}</p>
<a
href="mailto:hello@bookra.cz"
class="inline-flex items-center gap-2 text-accent hover:text-accent/80 font-medium transition-colors group/link"
>
hello@bookra.cz
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="transition-transform group-hover/link:translate-x-1">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</a>
</div>
</div>
</CardContent>
</Card>
{/* Hours Card */}
<Card class="surface-elevated group hover:shadow-xl transition-all duration-500">
<CardContent class="p-6">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center shrink-0 group-hover:bg-accent/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div>
<h3 class="font-display font-semibold text-ink mb-1">{i18n.t("contact.info.hours.title")}</h3>
<p class="text-ink-muted text-sm">{i18n.t("contact.info.hours.desc")}</p>
<div class="mt-3 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-success animate-pulse"/>
<span class="text-xs text-success font-medium">
{i18n.locale() === 'cs' ? 'Aktuálně online' : 'Currently online'}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Helpful mascot at bottom */}
<div class="mt-16 flex justify-center">
<div class="flex items-center gap-4 surface-elevated px-6 py-4 rounded-2xl">
<BookraCharacter pose="main" size="sm" animate={true} />
<p class="text-ink-muted text-sm">
{i18n.locale() === 'cs'
? 'Odpovídáme obvykle do 24 hodin'
: 'We usually respond within 24 hours'}
</p>
</div>
</div>
</div>
</div>
</section>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+672 -27
View File
@@ -1,40 +1,685 @@
import { A } from "@solidjs/router";
import { createSignal, onMount, JSX, createMemo } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
// Lucide-like icon components (lightweight SVG)
const CalendarIcon = () => (
<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">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
const ClockIcon = () => (
<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">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
const UsersIcon = () => (
<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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
const BellIcon = () => (
<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 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
const ShieldIcon = () => (
<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
const ZapIcon = () => (
<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">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
);
const ArrowRightIcon = () => (
<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">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
);
const CheckIcon = () => (
<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">
<polyline points="20 6 9 17 4 12" />
</svg>
);
const SunIcon = () => (
<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="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
);
const MoonIcon = () => (
<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="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
// Step card component
interface StepCardProps {
number: number;
title: string;
description: string;
}
const StepCard = (props: StepCardProps) => (
<div class="relative flex gap-5 lg:gap-6">
<div class="flex flex-col items-center">
<div class="w-10 h-10 rounded-full bg-ink text-canvas flex items-center justify-center text-sm font-semibold shrink-0 font-display">
{props.number}
</div>
<div class="w-px flex-1 bg-border my-3" />
</div>
<div class="pb-10">
<h3 class="font-display text-lg font-semibold text-ink mb-2">{props.title}</h3>
<p class="text-ink-muted leading-relaxed">{props.description}</p>
</div>
</div>
);
// Main home route component
export function HomeRoute() {
const i18n = useI18n();
const [isVisible, setIsVisible] = createSignal(false);
const [currentDate, setCurrentDate] = createSignal(new Date());
onMount(() => {
setIsVisible(true);
});
// Calendar helpers
const calendarMonth = createMemo(() => currentDate().getMonth());
const calendarYear = createMemo(() => currentDate().getFullYear());
const prevMonth = () => {
const newDate = new Date(currentDate());
newDate.setMonth(newDate.getMonth() - 1);
setCurrentDate(newDate);
};
const nextMonth = () => {
const newDate = new Date(currentDate());
newDate.setMonth(newDate.getMonth() + 1);
setCurrentDate(newDate);
};
// Generate calendar days for current month
const calendarDays = createMemo(() => {
const year = calendarYear();
const month = calendarMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDay = firstDay.getDay(); // 0 = Sunday, 1 = Monday, etc.
// Adjust for Monday start (0 = Monday)
const adjustedStart = startingDay === 0 ? 6 : startingDay - 1;
const days = [];
// Empty slots for days before the first day of month
for (let i = 0; i < adjustedStart; i++) {
days.push({ day: 0, isCurrentMonth: false });
}
// Days of the month
const today = new Date();
for (let i = 1; i <= daysInMonth; i++) {
const isToday = today.getDate() === i &&
today.getMonth() === month &&
today.getFullYear() === year;
// Generate some mock bookings (deterministic based on day)
const hasBooking = [3, 7, 12, 15, 18, 22, 26, 29].includes(i);
const hasMultiple = [7, 15, 22].includes(i);
days.push({
day: i,
isCurrentMonth: true,
isToday,
hasBooking,
hasMultiple
});
}
return days;
});
const features = [
{
icon: CalendarIcon,
title: i18n.t("home.feature.scheduling.title"),
description: i18n.t("home.feature.scheduling.desc"),
},
{
icon: UsersIcon,
title: i18n.t("home.feature.customers.title"),
description: i18n.t("home.feature.customers.desc"),
},
{
icon: BellIcon,
title: i18n.t("home.feature.reminders.title"),
description: i18n.t("home.feature.reminders.desc"),
},
{
icon: ClockIcon,
title: i18n.t("home.feature.availability.title"),
description: i18n.t("home.feature.availability.desc"),
},
{
icon: ZapIcon,
title: i18n.t("home.feature.quick.title"),
description: i18n.t("home.feature.quick.desc"),
},
{
icon: ShieldIcon,
title: i18n.t("home.feature.secure.title"),
description: i18n.t("home.feature.secure.desc"),
},
];
const steps = [
{
title: i18n.t("home.step1.title"),
description: i18n.t("home.step1.desc"),
},
{
title: i18n.t("home.step2.title"),
description: i18n.t("home.step2.desc"),
},
{
title: i18n.t("home.step3.title"),
description: i18n.t("home.step3.desc"),
},
];
const testimonials = [
{
quote: i18n.t("home.testimonial1.quote"),
author: i18n.t("home.testimonial1.author"),
role: i18n.t("home.testimonial1.role"),
},
{
quote: i18n.t("home.testimonial2.quote"),
author: i18n.t("home.testimonial2.author"),
role: i18n.t("home.testimonial2.role"),
},
{
quote: i18n.t("home.testimonial3.quote"),
author: i18n.t("home.testimonial3.author"),
role: i18n.t("home.testimonial3.role"),
},
];
return (
<section class="grid gap-8 py-10 lg:grid-cols-[1.4fr_0.8fr]">
<div class="rounded-panel border border-black/10 bg-white/70 p-8 shadow-card backdrop-blur">
<p class="mb-4 text-xs uppercase tracking-[0.24em] text-slate">{i18n.t("home.eyebrow")}</p>
<h1 class="max-w-3xl text-4xl font-semibold tracking-tight text-ink md:text-6xl">
{i18n.t("home.title")}
</h1>
<p class="mt-6 max-w-2xl text-lg leading-8 text-slate">{i18n.t("home.body")}</p>
<div class="mt-8 flex flex-wrap gap-3">
<A class="rounded-full bg-ink px-5 py-3 text-sm font-medium text-canvas" href="/dashboard">
{i18n.t("home.primary")}
</A>
<A class="rounded-full border border-black/10 px-5 py-3 text-sm font-medium" href="/book/studio-atelier">
{i18n.t("home.secondary")}
</A>
<div class="animate-fade-in">
{/* Hero Section */}
<section class="relative pt-16 pb-20 lg:pt-24 lg:pb-32 overflow-hidden">
<div
class="absolute inset-0 pointer-events-none animate-pulse-soft"
style={{ background: "var(--gradient-hero)" }}
/>
<div class="section-container relative">
<div class="max-w-3xl mx-auto text-center">
<span class="inline-flex items-center gap-2 px-4 py-1.5 mb-8 text-sm font-medium tracking-wide text-accent bg-accent-subtle/80 rounded-full border border-accent/10 backdrop-blur-sm">
<span class="w-2 h-2 rounded-full bg-accent animate-pulse" />
{i18n.t("home.badge")}
</span>
<h1 class="text-display-xl font-semibold text-ink mb-6 tracking-tight animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{i18n.t("home.hero.title")}
</h1>
<p class="text-lg lg:text-xl text-ink-muted mb-10 max-w-2xl mx-auto leading-relaxed animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{i18n.t("home.hero.subtitle")}
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<A
href="/dashboard"
class="btn-primary inline-flex items-center gap-2 text-base px-8 py-4 group"
>
{i18n.t("home.hero.cta.primary")}
<span class="transition-transform duration-200 group-hover:translate-x-1">
<ArrowRightIcon />
</span>
</A>
<a
href="https://demo.bookra.eu"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary inline-flex items-center gap-2 text-base px-8 py-4"
>
{i18n.locale() === 'cs' ? 'Otevřít demo mod' : 'Open demo mode'}
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
</div>
</div>
{/* Hero visual - abstract booking UI mockup with mascot */}
<div class="mt-16 lg:mt-20 max-w-4xl mx-auto animate-slide-up relative" style={{ "animation-delay": "0.4s" }}>
<div class="surface-glass rounded-panel p-1.5 lg:p-2 shadow-2xl border border-border/50 relative">
<div class="bg-canvas rounded-card overflow-hidden">
{/* Calendar Header */}
<div class="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-canvas-subtle/50">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
<CalendarIcon />
</div>
<span class="font-display font-semibold text-ink">
{i18n.t(`calendar.months.${calendarMonth()}`)} {calendarYear()}
</span>
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg border border-border flex items-center justify-center text-ink-muted hover:bg-canvas-subtle transition-colors"
onClick={prevMonth}
aria-label={i18n.t("calendar.prevMonth")}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
<button
class="w-8 h-8 rounded-lg border border-border flex items-center justify-center text-ink-muted hover:bg-canvas-subtle transition-colors"
onClick={nextMonth}
aria-label={i18n.t("calendar.nextMonth")}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-px bg-border/50">
{[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => (
<div class="bg-canvas-subtle/50 p-3 text-center text-xs font-medium text-ink-subtle uppercase tracking-wider">
{i18n.t(`calendar.days.${dayIndex}`)}
</div>
))}
{calendarDays().map((dayInfo) => {
if (!dayInfo.isCurrentMonth) {
return <div class="p-3 min-h-[80px] lg:min-h-[100px] bg-canvas/50" />;
}
return (
<div
class={`p-3 min-h-[80px] lg:min-h-[100px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group cursor-pointer ${
dayInfo.isToday ? "ring-2 ring-inset ring-accent bg-accent-soft" : ""
}`}
>
<span class={`text-sm transition-colors ${dayInfo.isToday ? "font-semibold text-accent" : "text-ink-muted group-hover:text-ink"}`}>
{dayInfo.day}
</span>
{dayInfo.hasBooking && (
<div class="mt-2 space-y-1.5">
<div class="h-2 bg-accent/30 rounded-full w-full group-hover:bg-accent/40 transition-colors" />
{dayInfo.hasMultiple && <div class="h-2 bg-accent/20 rounded-full w-3/4 group-hover:bg-accent/30 transition-colors" />}
<div class="h-2 bg-accent/10 rounded-full w-1/2 group-hover:bg-accent/20 transition-colors" />
</div>
)}
</div>
);
})}
</div>
</div>
{/* Floating mascot at bottom-right */}
<div class="absolute -bottom-6 -right-4 lg:-right-8 z-20 hidden md:block">
<BookraCharacter pose="hello" size="md" animate={true} />
</div>
</div>
</div>
</div>
</div>
<div class="grid gap-4">
<div class="rounded-panel border border-black/10 bg-pine p-6 text-canvas shadow-card">
<p class="text-sm uppercase tracking-[0.2em] text-canvas/70">{i18n.t("home.appLabel")}</p>
<p class="mt-4 text-2xl font-semibold">{i18n.t("home.appTitle")}</p>
<p class="mt-3 text-sm leading-7 text-canvas/80">
{i18n.t("home.appBody")}
</section>
{/* Social proof / Logos */}
<section class="py-12 border-y border-border/50 bg-canvas-subtle/50">
<div class="section-container">
<div class="mb-6 flex justify-center">
<img
src="/bookra-illustrations/logo_text_horizontal.svg"
alt="Bookra"
class="h-9 w-auto opacity-75"
/>
</div>
<p class="text-center text-sm text-ink-subtle mb-8 tracking-wide uppercase">
{i18n.t("home.trust")}
</p>
<div class="flex flex-wrap items-center justify-center gap-8 lg:gap-16 opacity-60">
{["Salon Ella", "Physio Care", "Massage Studio", "Yoga Flow", "Repair Pro"].map((name) => (
<span class="text-lg lg:text-xl font-medium text-ink-muted tracking-tight">
{name}
</span>
))}
</div>
</div>
<div class="rounded-panel border border-black/10 bg-white/70 p-6 shadow-card">
<p class="text-sm uppercase tracking-[0.2em] text-slate">{i18n.t("home.publicLabel")}</p>
<p class="mt-4 text-2xl font-semibold">{i18n.t("home.publicTitle")}</p>
<p class="mt-3 text-sm leading-7 text-slate">{i18n.t("home.publicBody")}</p>
</section>
{/* Features Section */}
<section id="features" class="py-20 lg:py-32">
<div class="section-container">
<div class="max-w-2xl mx-auto text-center mb-16 lg:mb-20">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{i18n.t("home.features.eyebrow")}
</span>
<h2 class="text-display-md font-semibold text-ink mb-4 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{i18n.t("home.features.title")}
</h2>
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{i18n.t("home.features.subtitle")}
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{features.map((feature, index) => (
<div
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:scale-[1.02] animate-slide-up"
style={{ "animation-delay": `${0.1 * (index + 3)}s` }}
>
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center text-accent mb-5 transition-all duration-300 group-hover:scale-110 group-hover:shadow-lg">
<feature.icon />
</div>
<h3 class="font-display text-lg font-semibold text-ink mb-2">{feature.title}</h3>
<p class="text-ink-muted leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</div>
</section>
</section>
{/* How it works */}
<section class="py-20 lg:py-32 bg-canvas-subtle/30">
<div class="section-container">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<div>
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase">
{i18n.t("home.how.eyebrow")}
</span>
<h2 class="text-display-md font-semibold text-ink mb-6">
{i18n.t("home.how.title")}
</h2>
<p class="text-lg text-ink-muted mb-8">
{i18n.t("home.how.subtitle")}
</p>
<div class="flex items-center gap-4 mb-8">
<A
href="/dashboard"
class="btn-primary inline-flex items-center gap-2"
>
{i18n.t("home.how.cta")}
<ArrowRightIcon />
</A>
{/* Character guide */}
<div class="hidden sm:block">
<BookraCharacter pose="walk" size="sm" animate={true} />
</div>
</div>
</div>
<div class="lg:pl-8">
{steps.map((step, index) => (
<StepCard
number={index + 1}
title={step.title}
description={step.description}
/>
))}
<div class="flex gap-5 lg:gap-6">
<div class="w-10 h-10 rounded-full bg-accent text-white flex items-center justify-center">
<CheckIcon />
</div>
<div>
<h3 class="text-lg font-semibold text-ink mb-1">
{i18n.t("home.step4.title")}
</h3>
<p class="text-accent font-medium">
{i18n.t("home.step4.desc")}
</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Testimonials */}
<section class="py-20 lg:py-32">
<div class="section-container">
<div class="max-w-2xl mx-auto text-center mb-16">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{i18n.t("home.testimonials.eyebrow")}
</span>
<h2 class="text-display-md font-semibold text-ink animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{i18n.t("home.testimonials.title")}
</h2>
</div>
<div class="grid md:grid-cols-3 gap-6 lg:gap-8">
{testimonials.map((t, index) => (
<div
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl animate-slide-up"
style={{ "animation-delay": `${0.15 * (index + 1)}s` }}
>
<div class="flex items-start gap-1 mb-4">
<svg class="w-6 h-6 text-accent/40" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
</svg>
</div>
<p class="text-ink-muted mb-6 leading-relaxed font-sans text-lg italic">{t.quote}</p>
<div class="flex items-center gap-3 pt-4 border-t border-border/50">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-accent-subtle to-accent-soft flex items-center justify-center">
<span class="font-display font-semibold text-accent text-sm">
{t.author.split(' ').map(n => n[0]).join('')}
</span>
</div>
<div>
<p class="font-display font-semibold text-ink text-sm">{t.author}</p>
<p class="text-ink-subtle text-xs">{t.role}</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" class="py-20 lg:py-32 bg-canvas-subtle/30">
<div class="section-container">
<div class="max-w-2xl mx-auto text-center mb-16">
<span class="text-sm font-medium text-accent mb-3 block tracking-wide uppercase animate-fade-in">
{i18n.t("home.pricing.eyebrow")}
</span>
<h2 class="text-display-md font-semibold text-ink mb-4 animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{i18n.t("home.pricing.title")}
</h2>
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.2s" }}>
{i18n.t("home.pricing.subtitle")}
</p>
</div>
<div class="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto items-center">
{/* Starter Plan */}
<div
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl hover:-translate-y-1 animate-slide-up"
style={{ "animation-delay": "0.3s" }}
>
<div class="mb-6">
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
{i18n.t("home.pricing.starter.name")}
</h3>
<p class="text-ink-muted">{i18n.t("home.pricing.starter.desc")}</p>
</div>
<div class="mb-6">
<span class="font-display text-4xl font-semibold text-ink">
{i18n.locale() === 'cs' ? '119 Kč' : '$5'}
</span>
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.starter.trial")}</p>
</div>
<ul class="space-y-3 mb-8">
{[i18n.t("home.pricing.starter.f1"), i18n.t("home.pricing.starter.f2"), i18n.t("home.pricing.starter.f3")].map((feature) => (
<li class="flex items-start gap-3 text-ink-muted">
<span class="mt-0.5 text-accent shrink-0">
<CheckIcon />
</span>
<span class="text-sm">{feature}</span>
</li>
))}
</ul>
<A
href="/dashboard"
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
>
{i18n.t("home.pricing.starter.cta")}
</A>
</div>
{/* Pro Plan - Highlighted */}
<div
class="group relative p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-2xl hover:-translate-y-2 animate-slide-up lg:scale-105"
style={{ "animation-delay": "0.4s" }}
>
{/* Gradient background for highlighted card */}
<div class="absolute inset-0 bg-gradient-to-b from-ink to-ink/95 rounded-card" />
{/* Popular badge */}
<div class="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<span class="px-3 py-1 bg-accent text-white text-xs font-display font-medium rounded-full shadow-lg">
{i18n.t("home.pricing.popular")}
</span>
</div>
<div class="relative z-10">
<div class="mb-6">
<h3 class="font-display text-lg font-semibold mb-1 text-canvas">
{i18n.t("home.pricing.pro.name")}
</h3>
<p class="text-canvas/70">{i18n.t("home.pricing.pro.desc")}</p>
</div>
<div class="mb-6">
<span class="font-display text-4xl font-semibold text-canvas">
{i18n.locale() === 'cs' ? '499 Kč' : '$20'}
</span>
<span class="text-canvas/60">{i18n.t("home.pricing.perMonth")}</span>
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.pro.trial")}</p>
</div>
<ul class="space-y-3 mb-8">
{[i18n.t("home.pricing.pro.f1"), i18n.t("home.pricing.pro.f2"), i18n.t("home.pricing.pro.f3"), i18n.t("home.pricing.pro.f4"), i18n.t("home.pricing.pro.f5")].map((feature) => (
<li class="flex items-start gap-3 text-canvas/80">
<span class="mt-0.5 text-accent shrink-0">
<CheckIcon />
</span>
<span class="text-sm">{feature}</span>
</li>
))}
</ul>
<A
href="/dashboard"
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 bg-canvas text-ink hover:bg-canvas-subtle w-full shadow-lg group-hover:shadow-xl"
>
{i18n.t("home.pricing.pro.cta")}
</A>
</div>
</div>
{/* Business Plan */}
<div
class="group surface-elevated p-6 lg:p-8 rounded-card transition-all duration-500 hover:shadow-xl hover:-translate-y-1 animate-slide-up"
style={{ "animation-delay": "0.5s" }}
>
<div class="mb-6">
<h3 class="font-display text-lg font-semibold mb-1 text-ink">
{i18n.t("home.pricing.biz.name")}
</h3>
<p class="text-ink-muted">{i18n.t("home.pricing.biz.desc")}</p>
</div>
<div class="mb-6">
<span class="font-display text-4xl font-semibold text-ink">
{i18n.locale() === 'cs' ? '1 199 Kč' : '$50'}
</span>
<span class="text-ink-muted">{i18n.t("home.pricing.perMonth")}</span>
<p class="mt-1 text-xs text-accent font-medium">{i18n.t("home.pricing.biz.trial")}</p>
</div>
<ul class="space-y-3 mb-8">
{[i18n.t("home.pricing.biz.f1"), i18n.t("home.pricing.biz.f2"), i18n.t("home.pricing.biz.f3"), i18n.t("home.pricing.biz.f4")].map((feature) => (
<li class="flex items-start gap-3 text-ink-muted">
<span class="mt-0.5 text-accent shrink-0">
<CheckIcon />
</span>
<span class="text-sm">{feature}</span>
</li>
))}
</ul>
<A
href="/dashboard"
class="block text-center py-3 px-6 rounded-button font-display font-medium transition-all duration-200 btn-secondary w-full"
>
{i18n.t("home.pricing.biz.cta")}
</A>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section class="py-20 lg:py-32 relative overflow-hidden">
<div class="absolute inset-0 pointer-events-none">
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-accent/5 rounded-full blur-3xl" />
</div>
<div class="section-container relative">
<div class="max-w-3xl mx-auto text-center">
{/* Celebratory mascot */}
<div class="flex justify-center mb-6">
<BookraCharacter pose="like" size="lg" animate={true} />
</div>
<h2 class="text-display-md font-semibold text-ink mb-6 animate-slide-up">
{i18n.t("home.cta.title")}
</h2>
<p class="text-lg text-ink-muted mb-10 max-w-xl mx-auto animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{i18n.t("home.cta.subtitle")}
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 animate-slide-up" style={{ "animation-delay": "0.2s" }}>
<A
href="/dashboard"
class="btn-primary inline-flex items-center gap-2 text-base px-8 py-4 group shadow-lg hover:shadow-xl transition-all duration-300"
>
{i18n.t("home.cta.primary")}
<span class="transition-transform duration-200 group-hover:translate-x-1">
<ArrowRightIcon />
</span>
</A>
<a
href="https://demo.bookra.eu"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary inline-flex items-center gap-2 text-base px-8 py-4"
>
{i18n.locale() === 'cs' ? 'Otevřít demo mod' : 'Open demo mode'}
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
</div>
</div>
</div>
</section>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useParams } from "@solidjs/router";
import { For } from "solid-js";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui";
import { BookraCharacter } from "../components/bookra-character";
import { useI18n } from "../providers/i18n-provider";
export function LegalRoute() {
const params = useParams();
const i18n = useI18n();
const kind = () => (params.kind === "terms" ? "terms" : "privacy");
const heroPose = () => (kind() === "terms" ? "flag" : "educate");
const helperPose = () => (kind() === "terms" ? "announcement" : "happy_note");
const sections = () =>
kind() === "terms"
? [
{
title: i18n.t("legal.terms.service.title"),
body: i18n.t("legal.terms.service.body"),
},
{
title: i18n.t("legal.terms.billing.title"),
body: i18n.t("legal.terms.billing.body"),
},
]
: [
{
title: i18n.t("legal.privacy.data.title"),
body: i18n.t("legal.privacy.data.body"),
},
{
title: i18n.t("legal.privacy.rights.title"),
body: i18n.t("legal.privacy.rights.body"),
},
];
return (
<section class="section-container py-16">
<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div class="max-w-3xl space-y-8">
<div class="space-y-4">
<h1 class="font-display text-4xl font-semibold text-ink">
{i18n.t(`legal.${kind()}.title`)}
</h1>
<p class="text-lg text-ink-muted">{i18n.t(`legal.${kind()}.body`)}</p>
</div>
<For each={sections()}>
{(section) => (
<Card class="surface-elevated">
<CardHeader>
<CardTitle>{section.title}</CardTitle>
</CardHeader>
<CardContent>
<p class="text-ink-muted">{section.body}</p>
</CardContent>
</Card>
)}
</For>
</div>
<aside class="lg:sticky lg:top-24">
<Card class="surface-elevated overflow-hidden">
<CardContent class="p-8 text-center">
<div class="mb-6 flex justify-center">
<BookraCharacter pose={heroPose()} size="xl" animate={true} />
</div>
<img
src="/bookra-illustrations/logo_text_vertical.svg"
alt="Bookra"
class="mx-auto mb-6 h-28 w-auto opacity-90"
/>
<p class="text-sm leading-relaxed text-ink-muted">
{kind() === "terms"
? i18n.locale() === "cs"
? "Pravidla držíme stručná, čitelná a navázaná na reálný provoz služby."
: "We keep terms short, readable, and tied to real product behavior."
: i18n.locale() === "cs"
? "Soukromí řešíme prakticky: minimum dat navíc, jasný účel a předvídatelné zpracování."
: "We handle privacy pragmatically: minimal extra data, clear purpose, and predictable processing."}
</p>
<div class="mt-6 flex justify-center">
<BookraCharacter pose={helperPose()} size="sm" animate={true} />
</div>
</CardContent>
</Card>
</aside>
</div>
</section>
);
}
@@ -0,0 +1,43 @@
import { A } from "@solidjs/router";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
export function NotFoundRoute() {
const i18n = useI18n();
return (
<div class="min-h-[60vh] flex flex-col items-center justify-center px-4 animate-fade-in">
<div class="text-center max-w-lg">
<div class="flex justify-center mb-8">
<BookraCharacter pose="404" size="xl" animate={false} />
</div>
<h1 class="text-display-xl font-semibold text-ink mb-4">
{i18n.locale() === 'cs' ? 'Stránka nenalezena' : 'Page Not Found'}
</h1>
<p class="text-lg text-ink-muted mb-8">
{i18n.locale() === 'cs'
? 'Omlouváme se, ale stránka kterou hledáte neexistuje.'
: 'Sorry, the page you are looking for does not exist.'}
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<A href="/" class="btn-primary inline-flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
{i18n.locale() === 'cs' ? 'Zpět domů' : 'Back Home'}
</A>
<A href="/contact" class="btn-secondary inline-flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
{i18n.locale() === 'cs' ? 'Kontaktujte nás' : 'Contact Us'}
</A>
</div>
</div>
</div>
);
}
+301 -64
View File
@@ -1,39 +1,60 @@
import { useParams } from "@solidjs/router";
import { A, useParams } from "@solidjs/router";
import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../lib/api-client";
import { useI18n } from "../providers/i18n-provider";
import type { components } from "@bookra/api-client/generated/types";
import { BookraCharacter } from "../components/bookra-character";
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Input, SkeletonCard, Textarea, Tooltip } from "../components/ui";
export function PublicBookingRoute() {
const params = useParams();
const i18n = useI18n();
const tenantSlug = () => params.tenantSlug ?? "studio-atelier";
const tenantSlug = () => params.tenantSlug;
const [bookingResult, setBookingResult] = createSignal<string | null>(null);
const [bookingError, setBookingError] = createSignal<string | null>(null);
const [submittingSlot, setSubmittingSlot] = createSignal<string | null>(null);
const [availability, { refetch }] = createResource(() =>
apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", {
const [customerName, setCustomerName] = createSignal("");
const [customerEmail, setCustomerEmail] = createSignal("");
const [notes, setNotes] = createSignal("");
const [availability, { refetch }] = createResource(() => {
const slug = tenantSlug();
if (!slug) return null;
return apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", {
params: {
path: {
tenantSlug: tenantSlug(),
tenantSlug: slug,
},
},
}),
);
});
});
const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => {
if (!customerName().trim() || !customerEmail().trim()) {
setBookingError(i18n.t("booking.customerRequired"));
return;
}
setSubmittingSlot(slot.startsAt);
setBookingResult(null);
setBookingError(null);
const slug = tenantSlug();
if (!slug) {
setBookingError(i18n.locale() === 'cs' ? 'Chybí identifikátor podniku' : 'Business identifier missing');
setSubmittingSlot(null);
return;
}
const response = await apiClient.POST("/v1/public/bookings", {
body: {
tenantSlug: tenantSlug(),
tenantSlug: slug,
bookingMode: slot.mode,
serviceId: slot.serviceId ?? undefined,
classSessionId: slot.classSessionId ?? undefined,
staffId: slot.staffId ?? undefined,
locationId: slot.locationId ?? undefined,
customerName: "Demo Customer",
customerEmail: "customer@bookra.dev",
customerName: customerName().trim(),
customerEmail: customerEmail().trim(),
notes: notes().trim(),
startsAt: slot.startsAt,
endsAt: slot.endsAt,
},
@@ -41,9 +62,10 @@ export function PublicBookingRoute() {
const payload = response as { data?: components["schemas"]["CreateBookingResponse"]; error?: unknown };
if (payload.error) {
setBookingResult(`Booking failed: ${String(payload.error)}`);
setBookingError(`${i18n.t("booking.failed")}: ${String(payload.error)}`);
} else if (payload.data) {
setBookingResult(`Created ${payload.data.status} booking ${payload.data.reference}`);
setBookingResult(`${i18n.t("booking.created")} ${payload.data.reference}`);
setNotes("");
}
setSubmittingSlot(null);
@@ -51,60 +73,275 @@ export function PublicBookingRoute() {
};
return (
<section class="grid gap-8 py-10 lg:grid-cols-[1.1fr_0.9fr]">
<div class="rounded-panel border border-black/10 bg-white/70 p-8 shadow-card">
<h1 class="text-3xl font-semibold tracking-tight">{i18n.t("booking.title")}</h1>
<p class="mt-3 max-w-2xl text-base leading-7 text-slate">{i18n.t("booking.body")}</p>
<Show when={bookingResult()}>
<div class="mt-6 rounded-panel border border-pine/20 bg-pine/10 px-4 py-3 text-sm text-pine">
{bookingResult()}
</div>
</Show>
<div class="mt-8 grid gap-3">
<Show
when={availability.latest?.data?.slots?.length}
fallback={
<div class="rounded-panel border border-dashed border-black/10 bg-canvas/70 p-5 text-sm leading-6 text-slate">
{i18n.t("booking.empty")}
</div>
}
>
<For each={availability.latest?.data?.slots ?? []}>
{(slot) => (
<article class="rounded-panel border border-black/10 bg-canvas/70 p-5">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-sm uppercase tracking-[0.2em] text-slate">{slot.mode}</p>
<h2 class="mt-2 text-xl font-semibold">{slot.label ?? "Bookable slot"}</h2>
<p class="mt-2 text-sm leading-6 text-slate">
{new Date(slot.startsAt).toLocaleString()} - {new Date(slot.endsAt).toLocaleTimeString()}
</p>
<Show when={typeof slot.remainingCapacity === "number"}>
<p class="mt-2 text-sm text-pine">Remaining capacity: {slot.remainingCapacity}</p>
</Show>
</div>
<button
class="rounded-full bg-ink px-5 py-3 text-sm font-medium text-canvas disabled:opacity-50"
disabled={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
type="button"
>
{submittingSlot() === slot.startsAt ? "Booking..." : "Book demo slot"}
</button>
</div>
</article>
)}
</For>
</Show>
</div>
</div>
<div class="rounded-panel border border-black/10 bg-pine p-8 text-canvas shadow-card">
<p class="text-sm uppercase tracking-[0.2em] text-canvas/70">Tenant slug</p>
<p class="mt-4 text-2xl font-semibold">{tenantSlug()}</p>
<p class="mt-4 text-sm leading-7 text-canvas/80">
This route is ready to consume live availability from the Go API once Neon-backed tenant data is configured.
<section class="section-container py-12 animate-fade-in">
{/* Header */}
<div class="max-w-3xl mb-10">
<h1 class="text-display-lg font-semibold text-ink mb-4 animate-slide-up">
{i18n.t("booking.title")}
</h1>
<p class="text-lg text-ink-muted animate-slide-up" style={{ "animation-delay": "0.1s" }}>
{i18n.t("booking.body")}
</p>
</div>
<Show when={!tenantSlug()}>
<div class="max-w-2xl mx-auto text-center py-16">
<BookraCharacter pose="404" size="lg" animate={false} class="mx-auto mb-6" />
<h2 class="text-display-md font-semibold text-ink mb-3">
{i18n.locale() === 'cs' ? 'Podnik nenalezen' : 'Business not found'}
</h2>
<p class="text-ink-muted mb-6">
{i18n.locale() === 'cs'
? 'Zkontrolujte URL adresu nebo kontaktujte provozovatele.'
: 'Please check the URL or contact the service provider.'}
</p>
<A href="/" class="btn-primary inline-flex items-center gap-2">
<HomeIcon />
{i18n.locale() === 'cs' ? 'Zpět na hlavní stránku' : 'Back to home'}
</A>
</div>
</Show>
<Show when={tenantSlug()}>
<div class="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
{/* Sidebar - Contact form - ORDER 1 on mobile, 2 on desktop */}
<div class="space-y-6 order-1 lg:order-2">
<Card class="surface-elevated animate-slide-up" style={{ "animation-delay": "0.3s" }}>
<CardHeader>
<CardTitle class="font-display text-xl">{i18n.t("booking.customer.title")}</CardTitle>
<CardDescription>{i18n.t("booking.customer.body")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Input
autocomplete="name"
label={i18n.t("booking.customer.name")}
onInput={(event) => setCustomerName(event.currentTarget.value)}
required
value={customerName()}
/>
<Input
autocomplete="email"
label={i18n.t("booking.customer.email")}
onInput={(event) => setCustomerEmail(event.currentTarget.value)}
required
type="email"
value={customerEmail()}
/>
<Textarea
label={i18n.t("booking.customer.notes")}
onInput={(event) => setNotes(event.currentTarget.value)}
rows={3}
value={notes()}
/>
</CardContent>
</Card>
<Card class="bg-gradient-to-br from-ink to-ink/95 text-canvas border-ink h-fit animate-slide-up shadow-xl" style={{ "animation-delay": "0.3s" }}>
<CardHeader>
<div class="flex items-center gap-2 mb-2">
<div class="w-8 h-8 rounded-lg bg-canvas/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-canvas/80">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<Badge variant="outline" class="border-canvas/30 text-canvas/70">{i18n.t("booking.business")}</Badge>
</div>
<div class="flex items-center gap-3">
<BookraCharacter pose="hello" size="sm" animate={true} />
<CardTitle class="font-display text-xl">{tenantSlug()}</CardTitle>
</div>
</CardHeader>
<CardContent class="space-y-4">
<p class="text-sm text-canvas/80 leading-relaxed">
{i18n.t("booking.sidebar.body")}
</p>
<div class="flex items-center gap-3 pt-4 border-t border-canvas/10">
<div class="w-10 h-10 rounded-full bg-canvas/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-canvas/70">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-canvas">{i18n.t("booking.help.title")}</p>
<p class="text-xs text-canvas/60">{i18n.t("booking.help.body")}</p>
</div>
</div>
</CardContent>
</Card>
{/* Quick info card */}
<Card class="surface-elevated animate-slide-up" style={{ "animation-delay": "0.4s" }}>
<CardContent class="p-6">
<h4 class="font-display font-semibold text-ink mb-3">{i18n.t("booking.expect.title")}</h4>
<ul class="space-y-2 text-sm text-ink-muted">
<li class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent mt-0.5 shrink-0">
<polyline points="20 6 9 17 4 12"/>
</svg>
{i18n.t("booking.expect.confirmation")}
</li>
<li class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent mt-0.5 shrink-0">
<polyline points="20 6 9 17 4 12"/>
</svg>
{i18n.t("booking.expect.reminders")}
</li>
<li class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent mt-0.5 shrink-0">
<polyline points="20 6 9 17 4 12"/>
</svg>
{i18n.t("booking.expect.rescheduling")}
</li>
</ul>
</CardContent>
</Card>
</div>
{/* Main booking card - Slots - ORDER 2 on mobile, 1 on desktop */}
<Card class="surface-elevated animate-slide-up order-2 lg:order-1" style={{ "animation-delay": "0.2s" }}>
<CardHeader>
<div class="flex items-center gap-2 mb-2">
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</div>
<Badge variant="secondary">{i18n.t("booking.slots")}</Badge>
</div>
<CardTitle class="font-display text-2xl">{i18n.t("booking.selectTime")}</CardTitle>
<CardDescription>{i18n.t("booking.selectTimeBody")}</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
{/* Success message */}
<Show when={bookingResult()}>
<div class="rounded-card border border-success/30 bg-success-subtle/50 px-4 py-5 text-sm text-success flex items-center gap-4 animate-scale-in">
<BookraCharacter pose="success" size="sm" animate={false} />
<div class="flex-1">
<p class="font-semibold text-success">{i18n.t("booking.confirmed")}</p>
<p class="text-success/80">{bookingResult()}</p>
</div>
</div>
</Show>
<Show when={bookingError()}>
<div class="rounded-card border border-error/30 bg-error-subtle/50 px-4 py-5 text-sm text-error flex items-center gap-4 animate-scale-in">
<BookraCharacter pose="lookup" size="sm" animate={false} class="opacity-80" />
<div class="flex-1">
<p class="font-semibold text-error">{i18n.t("booking.failed")}</p>
<p class="text-error/80">{bookingError()}</p>
</div>
</div>
</Show>
{/* Loading state */}
<Show when={availability.loading}>
<div class="space-y-4">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
</Show>
{/* Slots list */}
<div class="space-y-4">
<Show
when={!availability.loading && availability.latest?.data?.slots?.length}
fallback={
<Show when={!availability.loading}>
<div class="rounded-card border border-dashed border-border bg-canvas-subtle/50 p-8 text-center">
<BookraCharacter pose="sleep" size="md" animate={true} class="mx-auto mb-4" />
<p class="text-sm text-ink-muted font-medium">{i18n.t("booking.empty")}</p>
<p class="text-xs text-ink-subtle mt-2">{i18n.t("booking.emptyHint")}</p>
</div>
</Show>
}
>
<For each={availability.latest?.data?.slots ?? []}>
{(slot, index) => (
<Card
variant="default"
class="group surface-card hover:border-accent/30 transition-all duration-300"
style={{ "animation-delay": `${0.1 * (index() + 3)}s` }}
padding="lg"
>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-2">
<div class="flex items-center gap-2">
<Badge
variant={slot.mode === "appointment" ? "secondary" : "default"}
class="font-display text-xs uppercase tracking-wider"
>
{slot.mode}
</Badge>
<Show when={typeof slot.remainingCapacity === "number" && slot.remainingCapacity <= 3}>
<Badge variant="warning" size="sm">{i18n.t("booking.only")} {slot.remainingCapacity} {i18n.t("booking.left")}</Badge>
</Show>
</div>
<h3 class="font-display text-lg font-semibold text-ink">{slot.label ?? "Bookable slot"}</h3>
<div class="flex items-center gap-4 text-sm text-ink-muted">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{new Date(slot.startsAt).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
</span>
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{new Date(slot.startsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} - {new Date(slot.endsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<Show when={typeof slot.remainingCapacity === "number" && slot.mode === "class"}>
<p class="text-sm text-success font-medium flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{slot.remainingCapacity} {i18n.t("booking.spotsAvailable")}
</p>
</Show>
</div>
<Tooltip content="Book this slot now">
<Button
isLoading={submittingSlot() === slot.startsAt}
onClick={() => void bookSlot(slot)}
size="md"
class="shadow-md hover:shadow-lg transition-shadow whitespace-nowrap"
>
{submittingSlot() === slot.startsAt ? (
<>
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{i18n.t("booking.submitting")}
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{i18n.t("booking.submit")}
</>
)}
</Button>
</Tooltip>
</div>
</Card>
)}
</For>
</Show>
</div>
</CardContent>
</Card>
</div>
</Show>
</section>
);
}
const HomeIcon = () => (
<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="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
);
+580 -8
View File
@@ -1,17 +1,253 @@
/* =================================================================
Font Loading - Must be first
================================================================= */
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* =================================================================
CSS Custom Properties - Light Mode (Default)
================================================================= */
:root {
color-scheme: light dark;
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
background:
radial-gradient(circle at top left, rgba(157, 92, 61, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.64), transparent 100%);
color-scheme: light;
/* Canvas backgrounds - refined warm cream with subtle depth */
--canvas: 40 25% 97%;
--canvas-subtle: 40 20% 94%;
--canvas-muted: 40 15% 89%;
--canvas-elevated: 40 25% 99%;
--canvas-sunken: 40 15% 92%;
/* Text colors - ink with warmth */
--ink: 25 15% 12%;
--ink-muted: 25 10% 42%;
--ink-subtle: 25 8% 58%;
--ink-inverted: 40 25% 97%;
/* Accent - deep warm terracotta with sophistication */
--accent: 17 55% 42%;
--accent-hover: 17 60% 37%;
--accent-active: 17 65% 32%;
--accent-subtle: 17 45% 94%;
--accent-soft: 17 35% 96%;
--accent-muted: 17 30% 88%;
/* Semantic states */
--success: 145 45% 38%;
--success-hover: 145 50% 33%;
--success-subtle: 145 35% 94%;
--success-soft: 145 30% 96%;
--warning: 38 80% 50%;
--warning-hover: 38 85% 45%;
--warning-subtle: 38 60% 94%;
--error: 0 60% 52%;
--error-hover: 0 65% 47%;
--error-subtle: 0 50% 96%;
--error-soft: 0 40% 98%;
/* Info state */
--info: 210 60% 50%;
--info-subtle: 210 50% 94%;
/* Borders - refined neutrals with depth */
--border: 30 12% 86%;
--border-subtle: 35 15% 92%;
--border-strong: 25 10% 78%;
--border-focus: 17 55% 42%;
/* Shadows - refined depth system */
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.02);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.08), 0 8px 10px -6px rgb(0 0 0 / 0.04);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.12);
--shadow-glow: 0 0 20px hsl(17 55% 42% / 0.15);
/* Gradients - refined and sophisticated */
--gradient-subtle: linear-gradient(
180deg,
hsl(40 25% 97%) 0%,
hsl(40 20% 94%) 100%
);
--gradient-hero: radial-gradient(
80% 50% at 50% -10%,
hsl(17 45% 88% / 0.35) 0%,
transparent 55%
);
--gradient-card: linear-gradient(
145deg,
hsl(40 25% 98%) 0%,
hsl(40 20% 96%) 100%
);
--gradient-ink: linear-gradient(
145deg,
hsl(25 15% 15%) 0%,
hsl(25 15% 10%) 100%
);
/* Spacing scale - 8px base */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
--space-32: 8rem;
/* Animation timing */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
--duration-slower: 600ms;
}
/* =================================================================
CSS Custom Properties - Dark Mode
================================================================= */
:root.dark {
color-scheme: dark;
/* Canvas backgrounds - deep warm charcoal with depth */
--canvas: 25 10% 9%;
--canvas-subtle: 25 9% 13%;
--canvas-muted: 25 8% 19%;
--canvas-elevated: 25 10% 11%;
--canvas-sunken: 25 8% 7%;
/* Text colors - warm off-white */
--ink: 40 20% 95%;
--ink-muted: 35 12% 68%;
--ink-subtle: 30 10% 52%;
--ink-inverted: 25 10% 9%;
/* Accent - muted warm coral */
--accent: 17 55% 58%;
--accent-hover: 17 60% 63%;
--accent-active: 17 65% 68%;
--accent-subtle: 17 25% 18%;
--accent-soft: 17 20% 14%;
--accent-muted: 17 30% 25%;
/* Semantic states */
--success: 145 40% 55%;
--success-hover: 145 45% 60%;
--success-subtle: 145 25% 16%;
--success-soft: 145 20% 12%;
--warning: 38 75% 58%;
--warning-hover: 38 80% 63%;
--warning-subtle: 38 40% 16%;
--error: 0 55% 62%;
--error-hover: 0 60% 67%;
--error-subtle: 0 25% 18%;
--error-soft: 0 20% 12%;
/* Info state */
--info: 210 55% 60%;
--info-subtle: 210 30% 18%;
/* Borders - refined for dark mode */
--border: 25 10% 22%;
--border-subtle: 25 9% 16%;
--border-strong: 25 12% 30%;
--border-focus: 17 55% 58%;
/* Shadows - adapted for dark mode */
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.2);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.2);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.2);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.3);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.6), 0 8px 10px -6px rgb(0 0 0 / 0.3);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.7);
--shadow-glow: 0 0 30px hsl(17 55% 58% / 0.2);
/* Gradients - dark mode adaptations */
--gradient-subtle: linear-gradient(
180deg,
hsl(25 10% 9%) 0%,
hsl(25 9% 11%) 100%
);
--gradient-hero: radial-gradient(
80% 50% at 50% -10%,
hsl(17 35% 28% / 0.4) 0%,
transparent 55%
);
--gradient-card: linear-gradient(
145deg,
hsl(25 10% 11%) 0%,
hsl(25 9% 13%) 100%
);
--gradient-ink: linear-gradient(
145deg,
hsl(25 15% 20%) 0%,
hsl(25 15% 12%) 100%
);
/* Spacing scale - inherited from light */
/* Animation timing - inherited from light */
}
/* =================================================================
Base Styles
================================================================= */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: "Newsreader", Georgia, ui-serif, serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
font-feature-settings: "liga" 1, "calt" 1;
}
html,
body {
min-height: 100vh;
}
body {
min-height: 100vh;
background: var(--gradient-subtle);
color: hsl(var(--ink));
transition: background-color 0.3s ease, color 0.3s ease;
font-size: 16px;
line-height: 1.6;
}
/* Smooth theme transitions for all elements */
*,
*::before,
*::after {
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: 0.2s;
transition-timing-function: ease;
}
/* Override for transform/opacity animations */
[class*="animate-"] {
transition-property: transform, opacity;
}
a {
@@ -19,7 +255,343 @@ a {
text-decoration: none;
}
* {
box-sizing: border-box;
/* Typography refinements */
h1, h2, h3, h4, h5, h6 {
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.2;
}
h1 { font-size: clamp(2rem, 4vw + 0.5rem, 3rem); }
h2 { font-size: clamp(1.5rem, 3vw + 0.5rem, 2.25rem); }
h3 { font-size: clamp(1.25rem, 2vw + 0.5rem, 1.75rem); }
h4 { font-size: clamp(1.1rem, 1.5vw + 0.5rem, 1.4rem); }
p {
line-height: 1.7;
}
/* Focus styles */
:focus-visible {
outline: 2px solid hsl(var(--accent));
outline-offset: 2px;
}
/* Selection */
::selection {
background: hsl(var(--accent) / 0.22);
color: hsl(var(--ink));
}
/* =================================================================
Scrollbar
================================================================= */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--ink-muted) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--ink-muted) / 0.5);
}
/* =================================================================
Component Utilities
================================================================= */
@layer components {
.surface-card {
background: var(--gradient-card);
backdrop-filter: blur(16px);
border: 1px solid hsl(var(--border));
border-radius: 1rem;
}
.surface-elevated {
background: hsl(var(--canvas));
border: 1px solid hsl(var(--border));
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.04),
0 12px 48px -12px rgb(0 0 0 / 0.1);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.surface-elevated:hover {
box-shadow: var(--shadow-xl);
transform: translateY(-2px);
}
.surface-glass {
background: hsl(var(--canvas) / 0.8);
backdrop-filter: blur(20px) saturate(180%);
border: 1px solid hsl(var(--border) / 0.5);
}
.text-gradient {
background: linear-gradient(
135deg,
hsl(var(--ink)) 0%,
hsl(var(--accent)) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: hsl(var(--ink));
color: hsl(var(--canvas));
border: 1.5px solid hsl(var(--ink));
border-radius: 0.75rem;
padding: 0.875rem 1.75rem;
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
font-weight: 500;
font-size: 0.9375rem;
letter-spacing: -0.01em;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.btn-primary:hover {
background: hsl(var(--accent-hover));
border-color: hsl(var(--accent-hover));
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: transparent;
color: hsl(var(--ink));
border: 1.5px solid hsl(var(--border-strong));
border-radius: 0.75rem;
padding: 0.875rem 1.75rem;
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
font-weight: 500;
font-size: 0.9375rem;
letter-spacing: -0.01em;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.btn-secondary:hover {
background: hsl(var(--canvas-subtle));
border-color: hsl(var(--border));
}
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: transparent;
color: hsl(var(--ink-muted));
border: none;
border-radius: 0.75rem;
padding: 0.75rem 1.25rem;
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
font-weight: 500;
font-size: 0.9375rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.btn-ghost:hover {
color: hsl(var(--ink));
background: hsl(var(--canvas-subtle));
}
.input-field {
width: 100%;
background: hsl(var(--canvas));
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
padding: 0.875rem 1rem;
font-family: inherit;
font-size: 1rem;
color: hsl(var(--ink));
transition: all 0.2s ease;
}
.input-field:hover {
border-color: hsl(var(--border-strong));
}
.input-field:focus {
outline: none;
border-color: hsl(var(--accent));
box-shadow: 0 0 0 3px hsl(var(--accent) / 0.1);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
font-size: 0.8125rem;
font-weight: 500;
letter-spacing: 0.01em;
border-radius: 9999px;
background: hsl(var(--accent-subtle));
color: hsl(var(--accent));
}
.rounded-card {
border-radius: 1rem;
}
.rounded-panel {
border-radius: 1.25rem;
}
/* Dashboard typography override - sans-serif for data density */
.font-dashboard {
font-family: "DM Sans", ui-sans-serif, system-ui, sans-serif;
font-feature-settings: "ss01" 1, "ss02" 1;
letter-spacing: -0.01em;
}
.section-container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 1.5rem;
}
@media (min-width: 640px) {
.section-container {
padding: 0 2rem;
}
}
@media (min-width: 1024px) {
.section-container {
padding: 0 3rem;
}
}
/* Character float animation */
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-6px) rotate(1deg);
}
50% {
transform: translateY(-3px) rotate(0deg);
}
75% {
transform: translateY(-8px) rotate(-1deg);
}
}
.animate-float {
animation: float 4s ease-in-out infinite;
}
/* Gentle pulse for attention */
@keyframes gentle-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.03);
}
}
.animate-gentle-pulse {
animation: gentle-pulse 2s ease-in-out infinite;
}
/* Character hover lift effect */
.character-hover {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.character-hover:hover {
transform: translateY(-8px) scale(1.05);
}
.bookra-location-map .leaflet-container,
.bookra-location-map .leaflet-pane,
.bookra-location-map .leaflet-top,
.bookra-location-map .leaflet-bottom {
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
}
.bookra-location-map .leaflet-control-attribution {
background: hsl(var(--canvas) / 0.82);
color: hsl(var(--ink-muted));
font-size: 0.625rem;
backdrop-filter: blur(10px);
}
.bookra-location-map .leaflet-control-zoom {
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.bookra-location-map .leaflet-control-zoom a {
background: hsl(var(--canvas));
color: hsl(var(--ink));
border-color: hsl(var(--border));
}
.bookra-location-map .leaflet-popup-content-wrapper {
border-radius: 0.75rem;
background: hsl(var(--canvas));
color: hsl(var(--ink));
border: 1px solid hsl(var(--border));
box-shadow: var(--shadow-lg);
}
.bookra-location-map .leaflet-popup-tip {
background: hsl(var(--canvas));
}
.bookra-map-tiles-warm {
filter: saturate(0.78) sepia(0.08) contrast(0.96) brightness(1.02);
}
.bookra-map-marker span {
position: relative;
display: block;
width: 2rem;
height: 2rem;
border: 3px solid hsl(var(--canvas));
border-radius: 9999px;
background: var(--bookra-marker-color, hsl(var(--accent)));
box-shadow: 0 12px 26px rgb(0 0 0 / 0.22);
}
.bookra-map-marker span::after {
content: "";
position: absolute;
inset: 0.45rem;
border-radius: inherit;
background: hsl(var(--canvas) / 0.9);
}
}