mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-29 07:54:44 +00:00
cleanup
This commit is contained in:
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`;
|
||||
};
|
||||
@@ -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, "&")
|
||||
.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
Reference in new issue
Block a user