This commit is contained in:
Tomas Dvorak committed 2026-05-05 09:48:07 +02:00
1 parent d854614a87
commit 48c3e15a38
295 files changed
+178378 -1036

No files matched your search

@@ -0,0 +1,240 @@
import { JSX, createSignal, onMount, Show } from "solid-js";
export type CharacterPose =
| "main"
| "hello"
| "laptop"
| "sleep"
| "lookup"
| "success"
| "like"
| "walk"
| "announcement"
| "educate"
| "happy_note"
| "maintenance"
| "sad"
// New mascot poses
| "time"
| "flag"
| "error"
| "headphones"
| "checkbox"
| "coffee"
| "coffe"
| "angry_bulb"
| "anoyed_lamp"
// Grayscaled error states
| "404"
| "forbidden"
| "connection_error"
| "fixing";
interface BookraCharacterProps {
pose: CharacterPose;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
animate?: boolean;
class?: string;
alt?: string;
}
const sizeMap = {
xs: "w-16 h-16",
sm: "w-24 h-24",
md: "w-32 h-32",
lg: "w-48 h-48",
xl: "w-64 h-64",
"2xl": "w-80 h-80",
};
const illustrationMap: Record<CharacterPose, string> = {
main: "/bookra-illustrations/main.svg",
hello: "/bookra-illustrations/hello.svg",
laptop: "/bookra-illustrations/laptop.svg",
sleep: "/bookra-illustrations/sleep.svg",
lookup: "/bookra-illustrations/lookup.svg",
success: "/bookra-illustrations/success.svg",
like: "/bookra-illustrations/like.svg",
walk: "/bookra-illustrations/walk.svg",
announcement: "/bookra-illustrations/announcement.svg",
educate: "/bookra-illustrations/educate.svg",
happy_note: "/bookra-illustrations/happy-note.svg",
maintenance: "/bookra-illustrations/maintenance.svg",
sad: "/bookra-illustrations/sad.svg",
time: "/bookra-illustrations/clock.svg",
flag: "/bookra-illustrations/flag.svg",
error: "/bookra-illustrations/sad.svg",
headphones: "/bookra-illustrations/headphones.svg",
checkbox: "/bookra-illustrations/happy-note.svg",
coffee: "/bookra-illustrations/coffee.svg",
coffe: "/bookra-illustrations/coffee.svg",
angry_bulb: "/bookra-illustrations/angry-bulb.svg",
anoyed_lamp: "/bookra-illustrations/angry-bulb.svg",
"404": "/bookra-illustrations/404.svg",
forbidden: "/bookra-illustrations/forbidden.svg",
connection_error: "/bookra-illustrations/connection-error.svg",
fixing: "/bookra-illustrations/maintenance.svg",
};
export function BookraCharacter(props: BookraCharacterProps): JSX.Element {
const [isLoaded, setIsLoaded] = createSignal(false);
const [hasError, setHasError] = createSignal(false);
const sizeClass = () => sizeMap[props.size || "md"];
const animationClass = () => props.animate !== false ? "animate-float" : "";
const characterPath = () => illustrationMap[props.pose];
const label = () => props.alt || `Bookra illustration - ${props.pose.replaceAll("_", " ")}`;
return (
<div
class={`relative inline-flex items-center justify-center ${sizeClass()} ${props.class || ""}`}
aria-label={label()}
>
<Show when={!hasError()}>
<img
src={characterPath()}
alt={label()}
class={`w-full h-full object-contain transition-all duration-500 ${
isLoaded() ? "opacity-100 scale-100" : "opacity-0 scale-95"
} ${animationClass()}`}
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
/>
</Show>
<Show when={hasError()}>
<div class="w-full h-full rounded-2xl bg-accent/10 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-accent">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
</Show>
</div>
);
}
// Character with speech bubble for contextual messages
interface CharacterMessageProps extends BookraCharacterProps {
message: string;
position?: "left" | "right" | "top" | "bottom";
}
export function CharacterWithMessage(props: CharacterMessageProps): JSX.Element {
const positionClass = () => {
switch (props.position || "right") {
case "left": return "flex-row-reverse";
case "right": return "flex-row";
case "top": return "flex-col-reverse";
case "bottom": return "flex-col";
}
};
const bubblePosition = () => {
switch (props.position || "right") {
case "left": return "mr-4";
case "right": return "ml-4";
case "top": return "mb-3";
case "bottom": return "mt-3";
}
};
return (
<div class={`flex items-center ${positionClass()}`}>
<BookraCharacter
pose={props.pose}
size={props.size}
animate={props.animate}
class={props.class}
alt={props.alt}
/>
<div class={`surface-elevated px-4 py-3 rounded-2xl rounded-${
props.position === "left" ? "r" :
props.position === "right" ? "l" :
props.position === "top" ? "b" : "t"
}-lg max-w-xs ${bubblePosition()}`}>
<p class="text-ink text-sm leading-relaxed">{props.message}</p>
</div>
</div>
);
}
// Empty state with character
interface CharacterEmptyStateProps {
pose: CharacterPose;
title: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
size?: "sm" | "md" | "lg";
}
export function CharacterEmptyState(props: CharacterEmptyStateProps): JSX.Element {
return (
<div class="flex flex-col items-center text-center py-12 px-4">
<BookraCharacter
pose={props.pose}
size={props.size || "lg"}
animate={true}
class="mb-6"
/>
<h3 class="font-display text-xl font-semibold text-ink mb-2">
{props.title}
</h3>
<p class="text-ink-muted max-w-sm mb-6 leading-relaxed">
{props.message}
</p>
{props.action && (
<button
onClick={props.action.onClick}
class="btn-primary"
>
{props.action.label}
</button>
)}
</div>
);
}
// Success state with character celebration
interface CharacterSuccessStateProps {
title: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
export function CharacterSuccessState(props: CharacterSuccessStateProps): JSX.Element {
return (
<div class="flex flex-col items-center text-center py-12 px-4 animate-fade-in">
<div class="relative mb-6">
<BookraCharacter
pose="success"
size="xl"
animate={true}
/>
<div class="absolute -top-2 -right-2 text-3xl animate-bounce">✨</div>
</div>
<h3 class="font-display text-2xl font-semibold text-ink mb-3">
{props.title}
</h3>
<p class="text-ink-muted max-w-md mb-8 leading-relaxed">
{props.message}
</p>
{props.action && (
<button
onClick={props.action.onClick}
class="btn-primary inline-flex items-center gap-2"
>
{props.action.label}
</button>
)}
</div>
);
}
@@ -0,0 +1,178 @@
export const LayoutDashboardIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="9" x="3" y="3" rx="1" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
<rect width="7" height="5" x="14" y="3" rx="1" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
<rect width="7" height="9" x="14" y="12" rx="1" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
<rect width="7" height="5" x="3" y="16" rx="1" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
</svg>
);
export const CalendarDaysIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v4"/><path d="M16 2v4"/>
<rect width="18" height="18" x="3" y="4" rx="2" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
<path d="M3 10h18"/>
<path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/>
<path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/>
</svg>
);
export const CreditCardIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="14" x="2" y="5" rx="2" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
<line x1="2" x2="22" y1="10" y2="10"/>
</svg>
);
export const Settings2Icon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 7h-9"/><path d="M14 17H5"/>
<circle cx="17" cy="17" r="3" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
<circle cx="7" cy="7" r="3" class="transition-all duration-300 group-hover:fill-accent-subtle"/>
</svg>
);
export const LogOutIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" x2="9" y1="12" y2="12"/>
</svg>
);
export const MenuIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>
</svg>
);
export const XIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
);
export const TrendingUpIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
<polyline points="16 7 22 7 22 13"/>
</svg>
);
export const TrendingDownIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/>
<polyline points="16 17 22 17 22 11"/>
</svg>
);
export const ClockIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
);
export const CheckCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>
</svg>
);
export const AlertCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>
</svg>
);
export const ChevronLeftIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
);
export const ChevronRightIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
);
export const SparklesIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
</svg>
);
export const BellIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
</svg>
);
export const PlusIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/><path d="M12 5v14"/>
</svg>
);
export const UsersIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
);
export const UserCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="10" r="3"/>
<path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/>
</svg>
);
export const MapPinIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
);
export const BanIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="m4.9 4.9 14.2 14.2"/>
</svg>
);
export const ClockOffIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 7v5l3 3"/>
<circle cx="12" cy="12" r="10"/>
<path d="m2 2 20 20"/>
</svg>
);
export const SunIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>
</svg>
);
export const MoonIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
);
export const PaletteIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/>
<circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="12.5" r=".5"/>
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.6 1.6 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.01 17.461 2 12 2z"/>
</svg>
);
export const MailIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
);
@@ -0,0 +1,28 @@
import type { JSX } from "solid-js";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
export type BookingStatus = "confirmed" | "pending" | "cancelled";
export interface KpiData {
label: string;
value: number | string;
change?: string;
trend?: "up" | "down" | "neutral";
icon: () => JSX.Element;
}
export const getInitials = (name?: string) =>
(name ?? "User")
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("") || "U";
export const getBookingDuration = (startsAt?: string, endsAt?: string) => {
if (!startsAt || !endsAt) return "60 min";
const start = new Date(startsAt).getTime();
const end = new Date(endsAt).getTime();
if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return "60 min";
return `${Math.round((end - start) / 60000)} min`;
};
+4
View File
@@ -0,0 +1,4 @@
export { BookraCharacter } from "./bookra-character";
export { LocationMap } from "./location-map";
export { WidgetBuilder } from "./widget-builder";
export { IntegrationModal } from "./integration-modal";
@@ -0,0 +1,213 @@
import { createSignal, Show, For, type JSX } from "solid-js";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Button, Tabs, TabsList, TabsTrigger, TabsContent, DialogCloseButton } from "./ui";
interface IntegrationModalProps {
isOpen: boolean;
onClose: () => void;
tenantSlug: string;
publicBookingUrl: string;
tenantName: string;
primaryColor?: string;
}
export function IntegrationModal(props: IntegrationModalProps) {
const [copiedSnippet, setCopiedSnippet] = createSignal<string | null>(null);
const copyToClipboard = async (text: string, type: string) => {
await navigator.clipboard.writeText(text);
setCopiedSnippet(type);
setTimeout(() => setCopiedSnippet(null), 2000);
};
const hostedPageUrl = `https://bookra.eu/book/${props.tenantSlug}`;
const htmlWidgetCode = `<div id="bookra-widget"></div>
<script>
(function() {
var script = document.createElement('script');
script.src = "https://bookra.eu/widget.js";
script.async = true;
script.onload = function() {
BookraWidget.init({
tenantSlug: "${props.tenantSlug}",
container: "#bookra-widget",
theme: "light"
});
};
document.head.appendChild(script);
})();
</script>`;
const reactWidgetCode = `import { BookraWidget } from '@bookra/react-widget';
function App() {
return (
<BookraWidget
tenantSlug="${props.tenantSlug}"
theme="light"
/>
);
}`;
const solidWidgetCode = `import { BookraWidget } from '@bookra/solid-widget';
function App() {
return (
<BookraWidget
tenantSlug="${props.tenantSlug}"
theme="light"
/>
);
}`;
const phpWidgetCode = `<?php
// Add to your WordPress functions.php or PHP template
add_action('wp_footer', function() {
?>
<div id="bookra-widget"></div>
<script src="https://bookra.eu/widget.js" async></script>
<script>
window.addEventListener('load', function() {
BookraWidget.init({
tenantSlug: "${props.tenantSlug}",
container: "#bookra-widget",
theme: "light"
});
});
</script>
<?php
});`;
return (
<Dialog open={props.isOpen} onClose={props.onClose}>
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="text-2xl font-display">
Add Bookra to Your Website
</DialogTitle>
<DialogDescription>
Choose how you want to integrate Bookra with your business. Share a link or embed directly on your website.
</DialogDescription>
</DialogHeader>
<DialogCloseButton onClose={props.onClose} />
<Tabs defaultValue="hosted" class="mt-6">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="hosted">Hosted Page</TabsTrigger>
<TabsTrigger value="embed">Embed Widget</TabsTrigger>
</TabsList>
<TabsContent value="hosted" class="space-y-6 mt-6">
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-display font-semibold text-ink mb-3">Your Booking Page</h4>
<p class="text-sm text-ink-muted mb-4">
Share this link with your customers. They can book directly without any setup on your website.
</p>
<div class="flex items-center gap-3 p-4 bg-canvas rounded-xl border border-border">
<code class="flex-1 text-sm text-ink truncate font-mono">{hostedPageUrl}</code>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(hostedPageUrl, "url")}
>
{copiedSnippet() === "url" ? "Copied!" : "Copy"}
</Button>
</div>
<div class="mt-4 flex items-center gap-2 text-sm text-ink-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<span>Perfect for social media, email signatures, or direct sharing</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(hostedPageUrl)}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 p-3 rounded-xl bg-[hsl(220,70%,55%)]/10 text-[hsl(220,70%,55%)] hover:bg-[hsl(220,70%,55%)]/20 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Share on Facebook
</a>
<a
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(`Book your appointment with ${props.tenantName}!`)}&url=${encodeURIComponent(hostedPageUrl)}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 p-3 rounded-xl bg-[hsl(200,15%,10%)]/10 text-ink hover:bg-[hsl(200,15%,10%)]/20 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Share on X
</a>
</div>
</TabsContent>
<TabsContent value="embed" class="space-y-6 mt-6">
<div class="p-6 bg-canvas-subtle rounded-xl border border-border">
<h4 class="font-display font-semibold text-ink mb-3">Embed on Your Website</h4>
<p class="text-sm text-ink-muted mb-4">
Add the booking widget directly to your website. Choose your platform:
</p>
<Tabs defaultValue="html" class="w-full">
<TabsList class="grid w-full grid-cols-4 mb-4">
<TabsTrigger value="html">HTML/JS</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="solid">SolidJS</TabsTrigger>
<TabsTrigger value="php">PHP/WordPress</TabsTrigger>
</TabsList>
<For each={[
{ id: "html", label: "HTML/JavaScript", code: htmlWidgetCode },
{ id: "react", label: "React", code: reactWidgetCode },
{ id: "solid", label: "SolidJS", code: solidWidgetCode },
{ id: "php", label: "PHP/WordPress", code: phpWidgetCode },
]}>
{(item) => (
<TabsContent value={item.id} class="mt-0">
<div class="relative">
<pre class="p-4 bg-ink text-canvas rounded-xl overflow-x-auto text-sm font-mono"><code>{item.code}</code></pre>
<Button
variant="secondary"
size="sm"
class="absolute top-3 right-3"
onClick={() => copyToClipboard(item.code, item.id)}
>
{copiedSnippet() === item.id ? "Copied!" : "Copy"}
</Button>
</div>
</TabsContent>
)}
</For>
</Tabs>
<div class="mt-4 p-4 bg-[hsl(var(--info-subtle))] rounded-xl border border-[hsl(var(--info))/20]">
<div class="flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-[hsl(var(--info))] mt-0.5 shrink-0">
<circle cx="12" cy="12" r="10"/>
<line x1="12" x2="12" y1="16" y2="12"/>
<line x1="12" x2="12.01" y1="8" y2="8"/>
</svg>
<div>
<p class="text-sm font-medium text-ink">Need help with installation?</p>
<p class="text-sm text-ink-muted mt-1">
Contact our support team at <a href="mailto:[email protected]" 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/[email protected]/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/[email protected]/dist/leaflet.js";
script.async = true;
script.onload = () => resolve(window.L);
script.onerror = () => reject(new Error("Map library failed to load"));
document.head.appendChild(script);
});
return leafletPromise;
}
function safeMarkerColor(color: string | undefined): string {
const fallback = "#a65c3e";
if (!color) return fallback;
const trimmed = color.trim();
return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : fallback;
}
function escapeHtml(value: string | undefined): string {
return (value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function createMarkerIcon(leaflet: any, color: string | undefined) {
const safeColor = safeMarkerColor(color);
return leaflet.divIcon({
className: "bookra-map-marker",
html: `<span style="--bookra-marker-color:${safeColor}"></span>`,
iconSize: [34, 42],
iconAnchor: [17, 40],
popupAnchor: [0, -38],
});
}
export function LocationMap(props: LocationMapProps) {
let mapElement: HTMLDivElement | undefined;
let map: any;
let tileLayer: any;
let marker: any;
let leaflet: any;
const [isReady, setIsReady] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const zoom = () => props.zoom ?? props.coordinates.zoom ?? 15;
const mapStyle = () => resolveMapTileStyle(props.mapStyle ?? DEFAULT_MAP_STYLE_ID, props.customTileUrl);
const popupContent = () => {
const label = props.markerLabel ?? props.coordinates.address;
const address = props.coordinates.address && props.coordinates.address !== label ? props.coordinates.address : "";
if (!label && !address) return "";
return [
label ? `<strong>${escapeHtml(label)}</strong>` : "",
address ? `<span>${escapeHtml(address)}</span>` : "",
].filter(Boolean).join("<br>");
};
onMount(() => {
loadLeaflet()
.then((loadedLeaflet) => {
leaflet = loadedLeaflet;
setIsReady(true);
})
.catch((loadError: Error) => {
setError(loadError.message);
});
});
createEffect(() => {
if (!isReady() || !leaflet || !mapElement || map) return;
const { latitude, longitude } = props.coordinates;
if (!validateCoordinates(latitude, longitude)) {
setError("Invalid map coordinates");
return;
}
map = leaflet.map(mapElement, {
center: [latitude, longitude],
zoom: zoom(),
scrollWheelZoom: false,
dragging: props.interactive !== false,
touchZoom: props.interactive !== false,
doubleClickZoom: props.interactive !== false,
zoomControl: props.interactive !== false,
});
const style = mapStyle();
tileLayer = leaflet.tileLayer(style.url, {
attribution: style.attribution,
className: style.tileClassName ?? "",
maxZoom: 19,
}).addTo(map);
marker = leaflet
.marker([latitude, longitude], {
icon: createMarkerIcon(leaflet, props.markerColor),
})
.addTo(map);
const popup = popupContent();
if (popup) marker.bindPopup(popup);
setTimeout(() => map?.invalidateSize(), 120);
});
createEffect(() => {
if (!map || !marker) return;
const { latitude, longitude } = props.coordinates;
if (!validateCoordinates(latitude, longitude)) return;
map.setView([latitude, longitude], zoom());
marker.setLatLng([latitude, longitude]);
marker.setIcon(createMarkerIcon(leaflet, props.markerColor));
const popup = popupContent();
if (popup) {
marker.bindPopup(popup);
} else {
marker.unbindPopup();
}
});
createEffect(() => {
if (!map || !leaflet) return;
const style = mapStyle();
if (tileLayer) map.removeLayer(tileLayer);
tileLayer = leaflet.tileLayer(style.url, {
attribution: style.attribution,
className: style.tileClassName ?? "",
maxZoom: 19,
}).addTo(map);
});
onCleanup(() => {
if (map) {
map.remove();
map = null;
}
});
return (
<div
class={`bookra-location-map relative overflow-hidden rounded-card border border-border bg-canvas-subtle ${props.class ?? ""}`}
style={{ height: `${props.height ?? 360}px` }}
role="img"
aria-label={props.coordinates.address ? `Map for ${props.coordinates.address}` : "Location map"}
>
<div ref={mapElement} class="absolute inset-0" />
<Show when={!isReady() && !error()}>
<div class="absolute inset-0 grid place-items-center bg-canvas-subtle text-sm text-ink-muted">
Loading map...
</div>
</Show>
<Show when={error()}>
<div class="absolute inset-0 grid place-items-center bg-error-subtle px-6 text-center text-sm text-error">
{error()}
</div>
</Show>
</div>
);
}
+569 -35
View File
@@ -1,55 +1,589 @@
import { A } from "@solidjs/router";
import { ParentComponent, Show } from "solid-js";
import { A, useLocation, useNavigate } from "@solidjs/router";
import { ParentComponent, Show, createSignal, onMount, onCleanup } from "solid-js";
import { useAuth } from "../providers/auth-provider";
import { useI18n } from "../providers/i18n-provider";
import { useTheme } from "../providers/theme-provider";
import { BookraCharacter } from "./bookra-character";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "./ui";
// Icon components - refined with consistent sizing
const SunIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
);
const MoonIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
);
const GlobeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
const MenuIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
);
const XIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
const GoogleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#EA4335" d="M12 10.2v3.9h5.4c-.2 1.3-1.5 3.9-5.4 3.9-3.2 0-5.9-2.7-5.9-6s2.7-6 5.9-6c1.8 0 3 .8 3.7 1.5l2.5-2.4C16.7 3.6 14.6 2.7 12 2.7 6.9 2.7 2.8 6.8 2.8 12S6.9 21.3 12 21.3c6.9 0 8.6-6.4 8.6-9.6 0-.6-.1-1-.2-1.5H12Z" />
<path fill="#34A853" d="M3.9 7 7 9.2c.8-2.2 2.8-3.8 5-3.8 1.8 0 3 .8 3.7 1.5l2.5-2.4C16.7 3.6 14.6 2.7 12 2.7 8.2 2.7 4.9 4.9 3.9 7Z" />
<path fill="#4A90E2" d="M12 21.3c2.5 0 4.6-.8 6.1-2.2l-2.8-2.3c-.8.6-1.8 1-3.3 1-3.8 0-4.9-2.5-5.2-3.7l-3 2.3c1 2.1 4.2 4.9 8.2 4.9Z" />
<path fill="#FBBC05" d="M6.8 14.1a6.4 6.4 0 0 1 0-4.1L3.9 7a9.4 9.4 0 0 0 0 10l2.9-2.9Z" />
</svg>
);
export const Shell: ParentComponent = (props) => {
const auth = useAuth();
const i18n = useI18n();
const theme = useTheme();
const location = useLocation();
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
const hideHeader = () => location.pathname.startsWith("/dashboard");
const [signInOpen, setSignInOpen] = createSignal(false);
const [authMode, setAuthMode] = createSignal<"sign-in" | "register">("sign-in");
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [confirmPassword, setConfirmPassword] = createSignal("");
const [authError, setAuthError] = createSignal<string | null>(null);
const [authNotice, setAuthNotice] = createSignal<string | null>(null);
const [authSubmitting, setAuthSubmitting] = createSignal(false);
const showGoogleSignIn = () => auth.supportsGoogleSignIn() && authMode() === "sign-in";
const navLinks = [
{ href: "/about", label: i18n.t("nav.about") },
{ href: "/contact", label: i18n.t("nav.contact") },
];
// Demo mode detection
const isDemoMode = () => {
const hostname = window.location.hostname;
return hostname.includes("demo") || hostname === "localhost" || hostname === "127.0.0.1";
};
// Listen for custom event to open auth dialog from dashboard
onMount(() => {
const handleOpenAuth = (e: Event) => {
const customEvent = e as CustomEvent;
setAuthMode(customEvent.detail?.mode || "sign-in");
setSignInOpen(true);
};
window.addEventListener("openAuthDialog", handleOpenAuth);
onCleanup(() => {
window.removeEventListener("openAuthDialog", handleOpenAuth);
});
});
const submitSignIn = async () => {
setAuthSubmitting(true);
setAuthError(null);
setAuthNotice(null);
try {
if (authMode() === "register") {
if (password().length < 8) {
throw new Error(i18n.t("auth.passwordTooShort"));
}
if (password() !== confirmPassword()) {
throw new Error(i18n.t("auth.passwordMismatch"));
}
await auth.signUpWithEmail(name().trim(), email().trim(), password());
} else {
await auth.signInWithEmail(email().trim(), password());
}
setSignInOpen(false);
setName("");
setEmail("");
setPassword("");
setConfirmPassword("");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Translate common validation errors
const translatedError = translateAuthError(errorMessage, i18n.locale());
setAuthError(translatedError);
} finally {
setAuthSubmitting(false);
}
};
// Helper to translate validation error messages
const translateAuthError = (error: string, locale: string): string => {
// Email required errors
if (error.includes("'Email'")) {
if (locale === 'cs') {
return 'E-mail je povinný';
}
return 'Email is required';
}
// Password required errors
if (error.includes("'Password'")) {
if (locale === 'cs') {
return 'Heslo je povinné';
}
return 'Password is required';
}
// Name required errors (for registration)
if (error.includes("'Name'")) {
if (locale === 'cs') {
return 'Jméno je povinné';
}
return 'Name is required';
}
// Invalid credentials
if (error.toLowerCase().includes('invalid') || error.toLowerCase().includes('credentials')) {
if (locale === 'cs') {
return 'Neplatné přihlašovací údaje';
}
return 'Invalid credentials';
}
// Return original if no translation
return error;
};
const sendMagicLink = async () => {
setAuthSubmitting(true);
setAuthError(null);
setAuthNotice(null);
try {
await auth.sendMagicLink(email().trim());
setAuthNotice(i18n.t("auth.magicLinkSent"));
setPassword("");
} catch (error) {
setAuthError(error instanceof Error ? error.message : i18n.t("auth.signInFailed"));
} finally {
setAuthSubmitting(false);
}
};
const signInWithGoogle = async () => {
setAuthSubmitting(true);
setAuthError(null);
setAuthNotice(null);
try {
await auth.signInWithGoogle();
} catch (error) {
setAuthError(error instanceof Error ? error.message : i18n.t("auth.signInFailed"));
setAuthSubmitting(false);
}
};
return (
<div class="min-h-screen">
<header class="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
<A class="text-lg font-semibold tracking-tight" href="/">
Bookra
</A>
<nav class="flex items-center gap-3 text-sm text-slate">
<A class="rounded-full border border-black/10 px-4 py-2 hover:border-ember" href="/dashboard">
{i18n.t("nav.dashboard")}
</A>
<A class="rounded-full border border-black/10 px-4 py-2 hover:border-ember" href="/book/studio-atelier">
{i18n.t("nav.booking")}
</A>
<button
class="rounded-full border border-black/10 px-4 py-2 hover:border-pine"
onClick={() => i18n.toggleLocale()}
type="button"
>
{i18n.locale().toUpperCase()}
</button>
<Show
when={auth.session()}
fallback={
<div class="min-h-screen flex flex-col">
<Show when={!hideHeader()}>
<header class="sticky top-0 z-50 w-full border-b border-border/60 bg-canvas/75 backdrop-blur-xl">
<div class="section-container">
<div class="flex h-16 items-center justify-between">
{/* Logo */}
<A
class="flex items-center gap-2.5 text-lg font-display font-semibold tracking-tight text-ink hover:text-accent transition-all duration-300 group"
href="/"
onClick={() => window.scrollTo(0, 0)}
>
<img
src="/bookra-logo.svg"
alt="Bookra"
class="h-8 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
/>
</A>
{/* Desktop Navigation */}
<nav class="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<A
class="relative px-4 py-2 text-sm font-display font-medium text-ink-muted hover:text-ink transition-all duration-300 rounded-lg hover:bg-canvas-subtle group"
href={link.href}
activeClass="text-ink bg-canvas-subtle"
onClick={() => window.scrollTo(0, 0)}
>
{link.label}
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-0 h-0.5 bg-accent rounded-full transition-all duration-300 group-hover:w-4" />
</A>
))}
<div class="h-5 w-px bg-border mx-1" />
{/* Theme Toggle */}
<button
class="rounded-full bg-ink px-4 py-2 text-canvas"
onClick={() => void auth.signInDemo()}
class="p-2.5 rounded-lg text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-300 group relative overflow-hidden"
onClick={() => theme.toggle()}
type="button"
aria-label={theme.resolvedTheme() === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
<span class="relative z-10 transition-transform duration-300 group-hover:scale-110">
<Show when={theme.resolvedTheme() === "dark"} fallback={<SunIcon />}>
<MoonIcon />
</Show>
</span>
<span class="absolute inset-0 bg-canvas-subtle scale-0 group-hover:scale-100 transition-transform duration-300 rounded-lg" />
</button>
{/* Language Toggle */}
<button
class="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-display font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-200"
onClick={() => i18n.toggleLocale()}
type="button"
>
{i18n.t("auth.signIn")}
<GlobeIcon />
<span class="uppercase">{i18n.locale()}</span>
</button>
}
>
<div class="h-5 w-px bg-border mx-1" />
{/* Auth */}
<Show
when={auth.session()}
fallback={
<Show when={isDemoMode()} fallback={
<Button
size="sm"
onClick={() => {
setAuthMode("sign-in");
setSignInOpen(true);
}}
>
{i18n.t("auth.signIn")}
</Button>
}>
<Button
size="sm"
variant="secondary"
onClick={async () => {
await auth.signInAsDemo();
navigate("/dashboard");
}}
>
{i18n.locale() === 'cs' ? 'Demo režim - bez přihlášení' : 'Demo mode - no login'}
</Button>
</Show>
}
>
<Button
variant="secondary"
size="sm"
onClick={() => void auth.signOut()}
>
{i18n.t("auth.signOut")}
</Button>
</Show>
</nav>
{/* Mobile Menu Button */}
<button
class="rounded-full border border-black/10 px-4 py-2"
onClick={() => void auth.signOut()}
class="md:hidden p-2.5 rounded-lg text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-200"
onClick={() => setMobileMenuOpen(!mobileMenuOpen())}
type="button"
aria-label={mobileMenuOpen() ? "Close menu" : "Open menu"}
aria-expanded={mobileMenuOpen()}
>
{i18n.t("auth.signOut")}
<Show when={mobileMenuOpen()} fallback={<MenuIcon />}>
<XIcon />
</Show>
</button>
</Show>
</nav>
</div>
</div>
{/* Mobile Menu */}
<Show when={mobileMenuOpen()}>
<div class="md:hidden border-t border-border/60 bg-canvas/95 backdrop-blur-xl animate-slide-down">
<div class="section-container py-4 space-y-2">
{navLinks.map((link) => (
<A
class="block px-4 py-3 text-base font-display font-medium text-ink-muted hover:text-ink hover:bg-canvas-subtle rounded-lg transition-all duration-200"
href={link.href}
onClick={() => {
setMobileMenuOpen(false);
window.scrollTo(0, 0);
}}
>
{link.label}
</A>
))}
<div class="pt-2 border-t border-border/60 space-y-2">
<div class="flex items-center gap-4 px-4 py-2">
<button
class="flex items-center gap-2 p-2 rounded-lg text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-200"
onClick={() => theme.toggle()}
type="button"
>
<Show when={theme.resolvedTheme() === "dark"} fallback={<SunIcon />}>
<MoonIcon />
</Show>
<span class="text-sm font-medium">
{theme.resolvedTheme() === "dark" ? "Light mode" : "Dark mode"}
</span>
</button>
<button
class="flex items-center gap-2 p-2 rounded-lg text-ink-muted hover:text-ink hover:bg-canvas-subtle transition-all duration-200"
onClick={() => i18n.toggleLocale()}
type="button"
>
<GlobeIcon />
<span class="text-sm font-medium uppercase">{i18n.locale()}</span>
</button>
</div>
<Show
when={auth.session()}
fallback={
<Show when={isDemoMode()} fallback={
<Button
fullWidth
onClick={() => {
setAuthMode("sign-in");
setSignInOpen(true);
setMobileMenuOpen(false);
}}
>
{i18n.t("auth.signIn")}
</Button>
}>
<Button
fullWidth
variant="secondary"
onClick={async () => {
await auth.signInAsDemo();
setMobileMenuOpen(false);
navigate("/dashboard");
}}
>
{i18n.locale() === 'cs' ? 'Demo režim - bez přihlášení' : 'Demo mode - no login'}
</Button>
</Show>
}
>
<Button
variant="secondary"
fullWidth
onClick={() => {
void auth.signOut();
setMobileMenuOpen(false);
}}
>
{i18n.t("auth.signOut")}
</Button>
</Show>
</div>
</div>
</div>
</Show>
</header>
<main class="mx-auto max-w-7xl px-6 pb-16">{props.children}</main>
</Show>
<main class="flex-1">{props.children}</main>
{/* Footer */}
<footer class="border-t border-border/60 py-16 bg-canvas-subtle/40">
<div class="section-container">
<div class="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
{/* Logo & Description */}
<div class="md:col-span-2 space-y-4">
<A href="/" class="inline-flex items-center gap-4 group" onClick={() => window.scrollTo(0, 0)}>
<img
src="/bookra-logo.svg"
alt="Bookra"
class="h-10 w-auto transition-transform duration-300 group-hover:scale-105 dark:invert"
/>
</A>
<p class="text-ink-muted max-w-sm leading-relaxed">
{i18n.t("footer.description")}
</p>
</div>
{/* Navigation Links */}
<div class="space-y-4">
<h4 class="font-display font-semibold text-ink">{i18n.t("footer.links.title")}</h4>
<ul class="space-y-2">
<li>
<A href="/about" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("nav.about")}
</A>
</li>
<li>
<A href="/contact" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("nav.contact")}
</A>
</li>
<li>
<A href="/#pricing" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("home.pricing.title")}
</A>
</li>
</ul>
</div>
{/* Legal Links */}
<div class="space-y-4">
<h4 class="font-display font-semibold text-ink">{i18n.t("footer.legal.title")}</h4>
<ul class="space-y-2">
<li>
<A href="/privacy" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("footer.privacy")}
</A>
</li>
<li>
<A href="/terms" class="text-ink-muted hover:text-ink transition-colors duration-200" onClick={() => window.scrollTo(0, 0)}>
{i18n.t("footer.terms")}
</A>
</li>
</ul>
</div>
</div>
{/* Bottom Bar */}
<div class="pt-8 border-t border-border/60 flex flex-col md:flex-row items-center justify-between gap-4">
<p class="text-sm text-ink-subtle font-sans">
{i18n.t("footer.copyright")}
</p>
<div class="flex items-center gap-2 text-sm text-ink-subtle">
<span>Made with</span>
<span class="text-accent">♥</span>
<span>in Czech Republic</span>
</div>
</div>
</div>
</footer>
<Dialog open={signInOpen()} onClose={() => setSignInOpen(false)}>
<DialogHeader>
<div class="mb-2 flex justify-center">
<BookraCharacter
pose={authMode() === "register" ? "announcement" : "hello"}
size="md"
animate={true}
/>
</div>
<DialogTitle>{authMode() === "register" ? i18n.t("auth.registerTitle") : i18n.t("auth.signInTitle")}</DialogTitle>
<DialogDescription>{authMode() === "register" ? i18n.t("auth.registerBody") : i18n.t("auth.signInBody")}</DialogDescription>
</DialogHeader>
<DialogContent>
<form
class="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void submitSignIn();
}}
>
<Show when={authMode() === "register"}>
<Input
autocomplete="name"
label={i18n.t("auth.fullName")}
onInput={(event) => setName(event.currentTarget.value)}
required
value={name()}
/>
</Show>
<Input
autocomplete="email"
label={i18n.t("auth.email")}
onInput={(event) => setEmail(event.currentTarget.value)}
required
type="email"
value={email()}
/>
<Input
autocomplete="current-password"
label={i18n.t("auth.password")}
onInput={(event) => setPassword(event.currentTarget.value)}
required
type="password"
value={password()}
/>
<Show when={authMode() === "register"}>
<Input
autocomplete="new-password"
label={i18n.t("auth.confirmPassword")}
onInput={(event) => setConfirmPassword(event.currentTarget.value)}
required
type="password"
value={confirmPassword()}
/>
</Show>
<Show when={authError()}>
<p class="rounded-card border border-error/20 bg-error-subtle px-4 py-3 text-sm text-error">
{authError()}
</p>
</Show>
<Show when={authNotice()}>
<p class="rounded-card border border-success/20 bg-success-subtle px-4 py-3 text-sm text-success">
{authNotice()}
</p>
</Show>
<button class="hidden" type="submit" />
</form>
</DialogContent>
<DialogFooter class="flex-col gap-3">
<Show when={showGoogleSignIn()}>
<Button
variant="secondary"
onClick={() => void signInWithGoogle()}
fullWidth
disabled={authSubmitting()}
>
<span class="flex items-center justify-center gap-2">
<GoogleIcon />
{i18n.t("auth.continueWithGoogle")}
</span>
</Button>
</Show>
<Button
isLoading={authSubmitting()}
onClick={() => void submitSignIn()}
fullWidth
class="shadow-lg"
>
{authMode() === "register" ? i18n.t("auth.createAccount") : i18n.t("auth.signIn")}
</Button>
<Button
variant="ghost"
onClick={() => {
setAuthError(null);
setAuthNotice(null);
setAuthMode(authMode() === "register" ? "sign-in" : "register");
}}
fullWidth
>
{authMode() === "register" ? i18n.t("auth.signIn") : i18n.t("auth.createAccount")}
</Button>
</DialogFooter>
</Dialog>
</div>
);
};
@@ -0,0 +1,99 @@
import { JSX, Show, splitProps } from "solid-js";
interface AvatarProps extends JSX.HTMLAttributes<HTMLDivElement> {
src?: string;
alt?: string;
fallback?: string;
size?: "xs" | "sm" | "md" | "lg" | "xl";
variant?: "circle" | "rounded" | "square";
status?: "online" | "offline" | "away" | "busy";
}
export function Avatar(props: AvatarProps) {
const [local, rest] = splitProps(props, [
"src",
"alt",
"fallback",
"size",
"variant",
"status",
"class",
]);
const sizeClasses = {
xs: "w-6 h-6 text-xs",
sm: "w-8 h-8 text-sm",
md: "w-10 h-10 text-base",
lg: "w-12 h-12 text-lg",
xl: "w-16 h-16 text-xl",
};
const variantClasses = {
circle: "rounded-full",
rounded: "rounded-card",
square: "rounded-lg",
};
const getInitials = () => {
if (!local.fallback) return "?";
return local.fallback
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
};
const statusColors = {
online: "bg-success",
offline: "bg-ink-subtle",
away: "bg-warning",
busy: "bg-error",
};
const statusSizeClasses = {
xs: "w-2 h-2",
sm: "w-2.5 h-2.5",
md: "w-3 h-3",
lg: "w-3.5 h-3.5",
xl: "w-4 h-4",
};
return (
<div class="relative inline-flex" {...rest}>
<div
class={[
"relative overflow-hidden bg-accent-subtle flex items-center justify-center",
"font-display font-semibold text-accent",
sizeClasses[local.size || "md"],
variantClasses[local.variant || "circle"],
local.class || "",
].join(" ")}
>
<Show
when={local.src}
fallback={
<span class="select-none">{getInitials()}</span>
}
>
<img
src={local.src}
alt={local.alt || local.fallback || "Avatar"}
class="w-full h-full object-cover"
loading="lazy"
/>
</Show>
</div>
<Show when={local.status}>
<span
class={[
"absolute -bottom-0.5 -right-0.5 rounded-full ring-2 ring-canvas",
statusColors[local.status!],
statusSizeClasses[local.size || "md"],
].join(" ")}
/>
</Show>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { JSX, ParentComponent, splitProps } from "solid-js";
interface BadgeProps extends JSX.HTMLAttributes<HTMLSpanElement> {
variant?: "default" | "secondary" | "outline" | "success" | "warning" | "error";
size?: "sm" | "md";
}
export const Badge: ParentComponent<BadgeProps> = (props) => {
const [local, rest] = splitProps(props, [
"variant",
"size",
"children",
"class",
]);
const variantClasses = {
default: "bg-accent-subtle text-accent border-transparent",
secondary: "bg-canvas-muted text-ink-muted border-transparent",
outline: "bg-transparent text-ink-muted border-border",
success: "bg-[hsl(var(--success-subtle))] text-[hsl(var(--success))] border-transparent",
warning: "bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))] border-transparent",
error: "bg-[hsl(var(--error-subtle))] text-[hsl(var(--error))] border-transparent",
};
const sizeClasses = {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-1 text-sm",
};
const baseClasses = [
"inline-flex items-center gap-1.5 rounded-full font-display font-medium border transition-colors",
variantClasses[local.variant || "default"],
sizeClasses[local.size || "md"],
local.class || "",
].join(" ");
return (
<span {...rest} class={baseClasses}>
{local.children}
</span>
);
};
@@ -0,0 +1,74 @@
import { JSX, ParentComponent, splitProps } from "solid-js";
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
fullWidth?: boolean;
}
export const Button: ParentComponent<ButtonProps> = (props) => {
const [local, rest] = splitProps(props, [
"variant",
"size",
"isLoading",
"fullWidth",
"children",
"class",
]);
const variantClasses = {
primary: "btn-primary",
secondary: "btn-secondary",
ghost: "btn-ghost",
danger: "bg-error text-white border-error hover:bg-error-hover",
};
const sizeClasses = {
sm: "px-4 py-2 text-sm",
md: "px-5 py-2.5 text-[0.9375rem]",
lg: "px-6 py-3 text-base",
};
const baseClasses = [
"inline-flex items-center justify-center gap-2 rounded-button font-display font-medium tracking-tight transition-all duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:transform-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50",
variantClasses[local.variant || "primary"],
sizeClasses[local.size || "md"],
local.fullWidth ? "w-full" : "",
local.class || "",
].join(" ");
return (
<button
{...rest}
class={baseClasses}
disabled={rest.disabled || local.isLoading}
>
{local.isLoading && (
<svg
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{local.children}
</button>
);
};
+101
View File
@@ -0,0 +1,101 @@
import { JSX, ParentComponent, splitProps } from "solid-js";
interface CardProps extends JSX.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "elevated" | "ghost";
padding?: "none" | "sm" | "md" | "lg";
}
export const Card: ParentComponent<CardProps> = (props) => {
const [local, rest] = splitProps(props, [
"variant",
"padding",
"children",
"class",
]);
const variantClasses = {
default: "bg-canvas border border-border rounded-card",
elevated: "surface-elevated rounded-card",
ghost: "bg-transparent",
};
const paddingClasses = {
none: "",
sm: "p-4",
md: "p-6",
lg: "p-8",
};
const baseClasses = [
variantClasses[local.variant || "default"],
paddingClasses[local.padding || "md"],
local.class || "",
].join(" ");
return (
<div {...rest} class={baseClasses}>
{local.children}
</div>
);
};
export const CardHeader: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div {...rest} class={`mb-4 ${local.class || ""}`}>
{local.children}
</div>
);
};
export const CardTitle: ParentComponent<JSX.HTMLAttributes<HTMLHeadingElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<h3
{...rest}
class={`font-display text-lg font-semibold tracking-tight text-ink ${local.class || ""}`}
>
{local.children}
</h3>
);
};
export const CardDescription: ParentComponent<JSX.HTMLAttributes<HTMLParagraphElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<p {...rest} class={`text-sm text-ink-muted mt-1 ${local.class || ""}`}>
{local.children}
</p>
);
};
export const CardContent: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div {...rest} class={local.class || ""}>
{local.children}
</div>
);
};
export const CardFooter: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (
props
) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div
{...rest}
class={`mt-6 pt-6 border-t border-border flex items-center gap-3 ${local.class || ""}`}
>
{local.children}
</div>
);
};
+194
View File
@@ -0,0 +1,194 @@
import { JSX, ParentComponent, Show, createSignal, splitProps, onCleanup, onMount } from "solid-js";
import { Portal } from "solid-js/web";
interface DialogProps {
open: boolean;
onClose: () => void;
children: JSX.Element;
}
export const Dialog: ParentComponent<DialogProps> = (props) => {
const [local] = splitProps(props, ["open", "onClose", "children"]);
let overlayRef: HTMLDivElement | undefined;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && local.open) {
local.onClose();
}
};
const handleOverlayClick = (e: MouseEvent) => {
if (e.target === overlayRef) {
local.onClose();
}
};
onMount(() => {
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown);
});
});
// Lock body scroll when dialog is open
const originalOverflow = document.body.style.overflow;
if (local.open) {
document.body.style.overflow = "hidden";
}
onCleanup(() => {
if (!local.open) {
document.body.style.overflow = originalOverflow;
}
});
return (
<Portal>
<Show when={local.open}>
<div
ref={overlayRef}
class={[
"fixed inset-0 z-50",
"bg-ink/40 backdrop-blur-sm",
"animate-fade-in",
"flex items-center justify-center p-4",
].join(" ")}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
>
<div
class={[
"relative w-full max-w-lg",
"bg-canvas rounded-panel shadow-2xl",
"animate-scale-in",
"border border-border",
"max-h-[90vh] overflow-y-auto",
].join(" ")}
onClick={(e) => e.stopPropagation()}
>
{local.children}
</div>
</div>
</Show>
</Portal>
);
};
// Dialog Header
export const DialogHeader: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div class={["p-6 pb-4", local.class || ""].join(" ")} {...rest}>
{local.children}
</div>
);
};
// Dialog Title
export const DialogTitle: ParentComponent<JSX.HTMLAttributes<HTMLHeadingElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<h2
class={[
"font-display text-xl font-semibold text-ink",
local.class || "",
].join(" ")}
{...rest}
>
{local.children}
</h2>
);
};
// Dialog Description
export const DialogDescription: ParentComponent<JSX.HTMLAttributes<HTMLParagraphElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<p
class={[
"text-sm text-ink-muted mt-1",
local.class || "",
].join(" ")}
{...rest}
>
{local.children}
</p>
);
};
// Dialog Content
export const DialogContent: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div class={["p-6", local.class || ""].join(" ")} {...rest}>
{local.children}
</div>
);
};
// Dialog Footer
export const DialogFooter: ParentComponent<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [local, rest] = splitProps(props, ["children", "class"]);
return (
<div
class={[
"p-6 pt-4 border-t border-border",
"flex flex-col-reverse sm:flex-row sm:justify-end gap-3",
local.class || "",
].join(" ")}
{...rest}
>
{local.children}
</div>
);
};
// Dialog Close Button
interface DialogCloseButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
onClose: () => void;
}
export const DialogCloseButton: ParentComponent<DialogCloseButtonProps> = (props) => {
const [local, rest] = splitProps(props, ["onClose", "children", "class"]);
return (
<button
class={[
"absolute top-4 right-4",
"p-2 rounded-lg",
"text-ink-muted hover:text-ink hover:bg-canvas-subtle",
"transition-colors duration-200",
local.class || "",
].join(" ")}
onClick={local.onClose}
aria-label="Close dialog"
{...rest}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
);
};
// Hook for dialog state
export function useDialog(initialOpen = false) {
const [isOpen, setIsOpen] = createSignal(initialOpen);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev),
};
}
+11
View File
@@ -0,0 +1,11 @@
export { Button } from "./button";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./card";
export { Badge } from "./badge";
export { Input } from "./input";
export { Select } from "./select";
export { Textarea } from "./textarea";
export { Avatar } from "./avatar";
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
export { Tooltip } from "./tooltip";
export { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter, DialogCloseButton, useDialog } from "./dialog";
export { Skeleton, SkeletonCard, SkeletonText } from "./skeleton";
+40
View File
@@ -0,0 +1,40 @@
import { JSX, splitProps } from "solid-js";
interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
}
export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["label", "error", "hint", "class"]);
return (
<div class="w-full">
{local.label && (
<label class="block font-display text-sm font-medium text-ink mb-2">
{local.label}
</label>
)}
<input
{...rest}
class={[
"w-full bg-canvas border border-border rounded-button px-4 py-3",
"font-sans text-ink placeholder:text-ink-subtle",
"transition-all duration-200",
"hover:border-border-strong",
"focus:outline-none focus:border-accent focus:ring-3 focus:ring-accent/10",
"disabled:opacity-50 disabled:cursor-not-allowed",
local.error ? "border-error focus:border-error focus:ring-error/10" : "",
local.class || "",
].join(" ")}
/>
{local.error && (
<p class="mt-1.5 text-sm text-error">{local.error}</p>
)}
{local.hint && !local.error && (
<p class="mt-1.5 text-sm text-ink-muted">{local.hint}</p>
)}
</div>
);
}
@@ -0,0 +1,93 @@
import { JSX, For, splitProps, createSignal, Show } from "solid-js";
interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps extends Omit<JSX.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
label?: string;
error?: string;
hint?: string;
options: SelectOption[];
onChange?: (value: string) => void;
}
export function Select(props: SelectProps) {
const [local, rest] = splitProps(props, ["label", "error", "hint", "options", "onChange", "class"]);
const [isFocused, setIsFocused] = createSignal(false);
const handleChange = (e: Event) => {
const target = e.target as HTMLSelectElement;
local.onChange?.(target.value);
};
return (
<div class="w-full">
<Show when={local.label}>
<label class="block font-display text-sm font-medium text-ink mb-2">
{local.label}
</label>
</Show>
<div class="relative">
<select
{...rest}
onChange={handleChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
class={[
"w-full appearance-none bg-canvas border rounded-button px-4 py-3 pr-10",
"font-sans text-ink",
"transition-all duration-200",
"hover:border-border-strong",
"focus:outline-none focus:ring-3",
isFocused() ? "border-accent ring-accent/10" : "border-border",
local.error ? "border-error focus:border-error focus:ring-error/10" : "",
"cursor-pointer",
local.class || "",
].join(" ")}
>
<For each={local.options}>
{(option) => (
<option
value={option.value}
disabled={option.disabled}
class="py-2"
>
{option.label}
</option>
)}
</For>
</select>
{/* Custom dropdown arrow */}
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-ink-muted">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={isFocused() ? "rotate-180 transition-transform duration-200" : "transition-transform duration-200"}
>
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
</div>
<Show when={local.error}>
<p class="mt-1.5 text-sm text-error">{local.error}</p>
</Show>
<Show when={local.hint && !local.error}>
<p class="mt-1.5 text-sm text-ink-muted">{local.hint}</p>
</Show>
</div>
);
}
@@ -0,0 +1,50 @@
import { JSX } from "solid-js";
interface SkeletonProps extends JSX.HTMLAttributes<HTMLDivElement> {
width?: string;
height?: string;
circle?: boolean;
}
export function Skeleton(props: SkeletonProps) {
const { width, height, circle, class: className, ...rest } = props;
return (
<div
{...rest}
class={[
"animate-pulse bg-canvas-muted rounded-lg",
circle ? "rounded-full" : "",
className || "",
].join(" ")}
style={{
width: width || "100%",
height: height || "1rem",
}}
/>
);
}
export function SkeletonCard() {
return (
<div class="p-6 rounded-card border border-border bg-canvas/50 space-y-4">
<Skeleton width="60%" height="1.5rem" />
<Skeleton width="100%" height="1rem" />
<Skeleton width="80%" height="1rem" />
</div>
);
}
export function SkeletonText(props: { lines?: number }) {
const lines = props.lines || 3;
return (
<div class="space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
width={i === lines - 1 ? "75%" : "100%"}
height="1rem"
/>
))}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { JSX, For, createSignal, createContext, useContext, ParentComponent, splitProps, children, Accessor } from "solid-js";
import type { ResolvedChildren } from "solid-js";
// Context for tab state
interface TabsContextValue {
selectedTab: Accessor<string>;
setSelectedTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue>();
const useTabs = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error("useTabs must be used within a Tabs component");
}
return context;
};
// Tabs Root Component
interface TabsProps {
defaultValue: string;
value?: string;
onValueChange?: (value: string) => void;
children: JSX.Element;
class?: string;
}
export const Tabs: ParentComponent<TabsProps> = (props) => {
const [local, rest] = splitProps(props, ["defaultValue", "value", "onValueChange", "children", "class"]);
const [selectedTab, setSelectedTabInternal] = createSignal(local.defaultValue);
const setSelectedTab = (id: string) => {
setSelectedTabInternal(id);
local.onValueChange?.(id);
};
// If controlled, use the provided value
const currentTab = () => local.value ?? selectedTab();
const contextValue: TabsContextValue = {
selectedTab: currentTab,
setSelectedTab,
};
return (
<TabsContext.Provider value={contextValue}>
<div class={local.class} {...rest}>
{local.children}
</div>
</TabsContext.Provider>
);
};
// Tabs List Component
interface TabsListProps extends JSX.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "pills" | "underline";
}
export const TabsList: ParentComponent<TabsListProps> = (props) => {
const [local, rest] = splitProps(props, ["variant", "children", "class"]);
const variantClasses = {
default: "bg-canvas-muted p-1 rounded-card",
pills: "gap-1",
underline: "border-b border-border gap-4",
};
return (
<div
class={[
"flex items-center",
variantClasses[local.variant || "default"],
local.class || "",
].join(" ")}
role="tablist"
{...rest}
>
{local.children}
</div>
);
};
// Tab Trigger Component
interface TabsTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
}
export const TabsTrigger: ParentComponent<TabsTriggerProps> = (props) => {
const [local, rest] = splitProps(props, ["value", "children", "class"]);
const tabs = useTabs();
const isSelected = () => tabs.selectedTab() === local.value;
return (
<button
role="tab"
aria-selected={isSelected()}
class={[
"px-4 py-2 text-sm font-display font-medium transition-all duration-200",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2",
"rounded-button",
isSelected()
? "bg-canvas text-ink shadow-sm"
: "text-ink-muted hover:text-ink hover:bg-canvas-subtle",
local.class || "",
].join(" ")}
onClick={() => tabs.setSelectedTab(local.value)}
{...rest}
>
{local.children}
</button>
);
};
// Tab Content Component
interface TabsContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: string;
}
export const TabsContent: ParentComponent<TabsContentProps> = (props) => {
const [local, rest] = splitProps(props, ["value", "children", "class"]);
const tabs = useTabs();
const isSelected = () => tabs.selectedTab() === local.value;
return (
<div
role="tabpanel"
class={[
"mt-4",
isSelected() ? "block animate-fade-in" : "hidden",
local.class || "",
].join(" ")}
{...rest}
>
{local.children}
</div>
);
};
@@ -0,0 +1,57 @@
import { JSX, splitProps, createSignal } from "solid-js";
interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
rows?: number;
resize?: "none" | "vertical" | "horizontal" | "both";
}
export function Textarea(props: TextareaProps) {
const [local, rest] = splitProps(props, ["label", "error", "hint", "class", "rows", "resize"]);
const [isFocused, setIsFocused] = createSignal(false);
const resizeClass = () => {
switch (local.resize) {
case "none": return "resize-none";
case "vertical": return "resize-y";
case "horizontal": return "resize-x";
case "both": return "resize";
default: return "resize-y";
}
};
return (
<div class="w-full">
{local.label && (
<label class="block font-display text-sm font-medium text-ink mb-2">
{local.label}
</label>
)}
<textarea
{...rest}
rows={local.rows || 4}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
class={[
"w-full bg-canvas border rounded-button px-4 py-3",
"font-sans text-ink placeholder:text-ink-subtle",
"transition-all duration-200",
"hover:border-border-strong",
"focus:outline-none focus:ring-3",
isFocused() ? "border-accent ring-accent/10" : "border-border",
local.error ? "border-error focus:border-error focus:ring-error/10" : "",
resizeClass(),
local.class || "",
].join(" ")}
/>
{local.error && (
<p class="mt-1.5 text-sm text-error">{local.error}</p>
)}
{local.hint && !local.error && (
<p class="mt-1.5 text-sm text-ink-muted">{local.hint}</p>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import { JSX, ParentComponent, Show, createSignal, splitProps } from "solid-js";
interface TooltipProps {
content: string | JSX.Element;
placement?: "top" | "bottom" | "left" | "right";
delay?: number;
children: JSX.Element;
}
export const Tooltip: ParentComponent<TooltipProps> = (props) => {
const [local] = splitProps(props, ["content", "placement", "delay", "children"]);
const [isVisible, setIsVisible] = createSignal(false);
const [timeoutId, setTimeoutId] = createSignal<number | null>(null);
const placementClasses = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
const arrowClasses = {
top: "top-full left-1/2 -translate-x-1/2 -mt-1 border-t-canvas-elevated",
bottom: "bottom-full left-1/2 -translate-x-1/2 -mb-1 border-b-canvas-elevated",
left: "left-full top-1/2 -translate-y-1/2 -ml-1 border-l-canvas-elevated",
right: "right-full top-1/2 -translate-y-1/2 -mr-1 border-r-canvas-elevated",
};
const handleMouseEnter = () => {
const id = window.setTimeout(() => {
setIsVisible(true);
}, local.delay || 200);
setTimeoutId(id);
};
const handleMouseLeave = () => {
const id = timeoutId();
if (id) {
window.clearTimeout(id);
setTimeoutId(null);
}
setIsVisible(false);
};
return (
<div
class="relative inline-flex"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleMouseEnter}
onBlur={handleMouseLeave}
>
{local.children}
<Show when={isVisible()}>
<div
class={[
"absolute z-50 px-3 py-2",
"bg-canvas-elevated text-ink text-sm",
"font-sans rounded-lg shadow-lg border border-border",
"animate-scale-in origin-center",
placementClasses[local.placement || "top"],
].join(" ")}
role="tooltip"
>
{local.content}
{/* Arrow */}
<div
class={[
"absolute w-2 h-2 rotate-45",
"bg-canvas-elevated border border-border",
arrowClasses[local.placement || "top"],
].join(" ")}
/>
</div>
</Show>
</div>
);
};
File diff suppressed because it is too large. Load diff