mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
cleanup
This commit is contained in:
+31
-15
@@ -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`;
|
||||
};
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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: "© OpenStreetMap contributors © 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: "© OpenStreetMap contributors © 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: "© OpenStreetMap contributors © 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: "© OpenStreetMap contributors © CARTO",
|
||||
},
|
||||
{
|
||||
id: "openstreetmap",
|
||||
name: "OpenStreetMap",
|
||||
description: "Standard OSM tiles.",
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: "© 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 © 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: "© 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")!,
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user