Initial commit. Working login, example UI with prototype and demo, connections to DB and DAV, local and prod setups.

This commit is contained in:
2025-10-12 22:13:19 +01:00
commit 708b741a32
138 changed files with 7797 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Add this to your app.css or global styles */
.calendar-container {
z-index: 49 !important;
}
/* Or target the specific calendar component if needed */
[data-calendar] {
z-index: 49 !important;
}
+14
View File
@@ -0,0 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export { };
declare module 'swiper/svelte';
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,70 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import Calendar from '$lib/components/ui/calendar/calendar.svelte';
import { CalendarDate, getLocalTimeZone } from '@internationalized/date';
let value = $state<CalendarDate | undefined>(new CalendarDate(2025, 6, 12));
let selectedTime = $state<string | null>('10:00');
const bookedDates = Array.from({ length: 3 }, (_, i) => new CalendarDate(2025, 6, 17 + i));
const timeSlots = Array.from({ length: 37 }, (_, i) => {
const totalMinutes = i * 15;
const hour = Math.floor(totalMinutes / 60) + 9;
const minute = totalMinutes % 60;
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
});
</script>
<Card.Root class="gap-0 p-0">
<Card.Content class="relative p-0 md:pr-48">
<div class="p-6">
<Calendar
type="single"
bind:value
isDateUnavailable={(date) => bookedDates.some((d) => d.compare(date) === 0)}
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:hidden"
weekdayFormat="short"
/>
</div>
<div
class="no-scrollbar inset-y-0 right-0 flex max-h-72 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-t-0 md:border-l"
>
<div class="grid gap-2">
{#each timeSlots as time (time)}
<Button
variant={selectedTime === time ? 'default' : 'outline'}
onclick={() => (selectedTime = time)}
class="w-full"
>
{time}
</Button>
{/each}
</div>
</div>
</Card.Content>
<Card.Footer class="flex flex-col gap-4 border-t px-6 !py-5 md:flex-row">
<div class="text-sm">
{#if value && selectedTime}
Your meeting is booked for
<span class="font-medium">
{value.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'short'
})}
</span>
at <span class="font-medium">{selectedTime}</span>.
{:else}
Select a date and time for your meeting.
{/if}
</div>
<Button
disabled={!value || !selectedTime}
class="w-full md:ml-auto md:w-auto"
variant="outline"
>
Continue
</Button>
</Card.Footer>
</Card.Root>
@@ -0,0 +1,112 @@
<script lang="ts">
export let name: string = 'Chelsea Russell';
export let role: string = 'Owner / Beauty Specialist';
export let phone: string = '+44 8008135';
export let email: string = 'chelsea@emailaddress.com';
export let instagram: string = '@crussell';
export let address: string = 'Business Centre, Office Street, Work';
export let profileImage: string =
'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png';
export let altText: string = 'Profile picture';
</script>
<div class="mx-auto max-w-sm">
<div class="rounded-lg border-3 border-gray-200 bg-white p-6">
<!-- Profile Picture -->
<div class="mb-4 flex justify-center">
<div class="h-24 w-24 overflow-hidden rounded-full border-4 border-white shadow-lg">
<img src={profileImage} alt={altText} class="h-full w-full object-cover" />
</div>
</div>
<!-- Name and Role -->
<div class="mb-4 text-center">
<h2 class="text-xl font-semibold text-gray-900">{name}</h2>
<p class="text-gray-500">{role}</p>
</div>
<!-- Contact Info -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-map-pin"
><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle
cx="12"
cy="10"
r="3"
></circle></svg
>
<span>{address}</span>
</div>
<div class="flex items-center space-x-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-phone"
><path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
></path></svg
>
<a href={`tel:${phone}`} class="text-primary hover:underline">{phone}</a>
</div>
<div class="flex items-center space-x-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-mail"
><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
></path><polyline points="22,6 12,13 2,6"></polyline></svg
>
<a href={`mailto:${email}`} class="text-primary hover:underline">{email}</a>
</div>
<div class="flex items-center space-x-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-instagram"
><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path
d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"
></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg
>
<a
href={`https://instagram.com/${instagram}`}
target="_blank"
rel="noopener"
class="text-primary hover:underline">@{instagram}</a
>
</div>
</div>
</div>
</div>
@@ -0,0 +1,142 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { navigating, page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
// Mobile menu state
let mobileMenuOpen: boolean = false;
function toggleMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
// Close mobile menu when navigation starts
$: if ($navigating) {
mobileMenuOpen = false;
}
</script>
<nav
class="fixed left-0 top-0 z-50 w-full border-b border-gray-200"
class:frosty-nav={!mobileMenuOpen}
class:bg-background={mobileMenuOpen}
>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<!-- Left: Social Icons -->
<div class="flex items-center space-x-4">
<a
href="https://instagram.com"
target="_blank"
rel="noopener"
class="hover:text-primary text-gray-600"
aria-label="Instagram"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-instagram"
><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path
d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"
></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg
>
</a>
<!-- Add more social icons here -->
</div>
<!-- Center: Links -->
<div class="hidden space-x-8 md:flex">
<a href="/" class="hover:text-primary font-medium text-gray-800">Home</a>
{#if !authStore?.isAuthenticated}
<a href="/prices" class="hover:text-primary font-medium text-gray-800">Price List</a>
{:else}
<a href="/book" class="hover:text-primary font-medium text-gray-800"
>Book your appointment</a
>
{/if}
<a href="/portfolio" class="hover:text-primary font-medium text-gray-800">Portfolio</a>
<a href="/contact" class="hover:text-primary font-medium text-gray-800">Contact</a>
{#if authStore?.isAuthenticated}
<a href="/account" class="hover:text-primary font-medium text-gray-800">My Account</a>
{/if}
</div>
<div class="hidden items-center md:flex">
<!-- if not logged in and not on login page -->
{#if !authStore?.isAuthenticated && $page.url.pathname !== '/login'}
<Button href="/login">Login</Button>
{/if}
</div>
<!-- Mobile: Burger -->
<div class="flex items-center md:hidden">
<button on:click={toggleMenu} class="focus:outline-none" aria-label="Toggle menu">
<svg class="h-6 w-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
{#if mobileMenuOpen}
<div class="bg-background border-b border-gray-200 md:hidden">
<div class="space-y-1 px-2 pb-3 pt-2">
<a href="/" class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
>Home</a
>
{#if !authStore?.isAuthenticated}
<a
href="/prices"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
>Price list</a
>
{:else}
<a
href="/book"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
>Book your appointment</a
>
{/if}
<a
href="/portfolio"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800">Portfolio</a
>
<a
href="/contact"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800">Contact</a
>
{#if !authStore?.isAuthenticated}
<Button href="/login" class="mt-2 w-full text-center">Login</Button>
{:else}
<a
href="/Account"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
>My Account</a
>
{/if}
</div>
</div>
{/if}
</nav>
<style>
.frosty-nav {
backdrop-filter: saturate(180%) blur(10px);
}
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
// Sample portfolio images - replace with your actual nail art images
const portfolioImages = [
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Hand%2C_fingers_-_back.jpg/1024px-Hand%2C_fingers_-_back.jpg',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Palm%2C_fingers.jpg/1024px-Palm%2C_fingers.jpg',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/52/Hand_parts_-_en.svg/1920px-Hand_parts_-_en.svg.png',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Scheme_human_hand_bones-en.svg/1920px-Scheme_human_hand_bones-en.svg.png',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/a/a1/3D_Medical_Animation_Human_Wrist.jpg',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Wrist_and_hand_deeper_palmar_dissection-en.svg/1920px-Wrist_and_hand_deeper_palmar_dissection-en.svg.png',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Wrist_extensor_compartments_%28numbered%29.PNG',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Gray812and814.svg/1920px-Gray812and814.svg.png',
alt: 'lorem ipsum'
}
];
</script>
<section class="w-full overflow-hidden py-16">
<div class="mx-auto max-w-6xl px-6">
<h2 class="mb-12 text-center text-2xl font-semibold">Our Portfolio</h2>
</div>
<div class="relative w-full overflow-hidden">
<div class="animate-scroll flex">
{#each [...portfolioImages, ...portfolioImages] as image}
<div class="group flex-shrink-0">
<img
src={image.src}
alt={image.alt}
class="h-80 w-64 object-cover saturate-100 transition-all duration-500 md:saturate-25 md:group-hover:saturate-100"
loading="lazy"
/>
</div>
{/each}
</div>
</div>
</section>
<style>
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-scroll {
animation: scroll 40s linear infinite;
width: calc(256px * 16); /* 16 images total (8 duplicated) * 256px width */
}
/* images scale on hover */
.group:hover img {
transform: scale(1.05);
}
</style>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
export let forId: string;
export let text: string;
</script>
<Label for={forId}>
{text}
<span class="ml-1 font-bold" style="color: var(--chart-1)"> * </span>
</Label>
@@ -0,0 +1,81 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-fuchsia-200 hover:text-foreground',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,76 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type Calendar from "./calendar.svelte";
import CalendarMonthSelect from "./calendar-month-select.svelte";
import CalendarYearSelect from "./calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
let {
captionLayout,
months,
monthFormat,
years,
yearFormat,
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
}: {
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
months: ComponentProps<typeof CalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof CalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
monthIndex: number;
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md",
className
)}
{...restProps}
/>
@@ -0,0 +1,35 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
// Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}
/>
@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridProps = $props();
</script>
<CalendarPrimitive.Grid
bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadCellProps = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
className
)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeaderProps = $props();
</script>
<CalendarPrimitive.Header
bind:ref
class={cn(
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
className
)}
{...restProps}
/>
@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadingProps = $props();
</script>
<CalendarPrimitive.Heading
bind:ref
class={cn("px-(--cell-size) text-sm font-medium", className)}
{...restProps}
/>
@@ -0,0 +1,44 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
onchange,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
className
)}
>
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, monthItems, selectedMonthItem })}
<select {...props} {value} {onchange}>
{#each monthItems as monthItem (monthItem.value)}
<option
value={monthItem.value}
selected={value !== undefined
? monthItem.value === value
: monthItem.value === selectedMonthItem.value}
>
{monthItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.MonthSelect>
</span>
@@ -0,0 +1,15 @@
<script lang="ts">
import { type WithElementRef, cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
{@render children?.()}
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
{...restProps}
bind:this={ref}
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
>
{@render children?.()}
</nav>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: CalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronRightIcon class="size-4" />
{/snippet}
<CalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: CalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
{/snippet}
<CalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>
@@ -0,0 +1,43 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
className
)}
>
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, yearItems, selectedYearItem })}
<select {...props} {value}>
{#each yearItems as yearItem (yearItem.value)}
<option
value={yearItem.value}
selected={value !== undefined
? yearItem.value === value
: yearItem.value === selectedYearItem.value}
>
{yearItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
aria-hidden="true"
>
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.YearSelect>
</span>
@@ -0,0 +1,120 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import * as Calendar from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ButtonVariant } from '../button/button.svelte';
import { isEqualMonth, type DateValue } from '@internationalized/date';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
class: className,
weekdayFormat = 'short',
buttonVariant = 'ghost',
captionLayout = 'label',
locale = 'en-US',
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = 'numeric',
day,
disableDaysOutsideMonth = false,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
buttonVariant?: ButtonVariant;
captionLayout?: 'dropdown' | 'dropdown-months' | 'dropdown-years' | 'label';
months?: CalendarPrimitive.MonthSelectProps['months'];
years?: CalendarPrimitive.YearSelectProps['years'];
monthFormat?: CalendarPrimitive.MonthSelectProps['monthFormat'];
yearFormat?: CalendarPrimitive.YearSelectProps['yearFormat'];
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
} = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith('dropdown')) return 'short';
return 'long';
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<CalendarPrimitive.Root
bind:value={value as never}
bind:ref
bind:placeholder
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
// Add fuchsia theme styles for selected date
'[&_[data-selected]]:border-primary [&_[data-selected]]:text-primary [&_[data-selected]]:rounded-md [&_[data-selected]]:bg-fuchsia-200 [&_[data-selected]]:shadow-sm',
'[&_[data-selected]:hover]:bg-fuchsia-50',
// Ensure today indicator works with fuchsia theme
'[&_[data-today]:not([data-selected])]:bg-accent [&_[data-today]:not([data-selected])]:text-accent-foreground',
className
)}
{locale}
{monthFormat}
{yearFormat}
{...restProps}
>
{#snippet children({ months, weekdays })}
<Calendar.Months>
<Calendar.Nav>
<Calendar.PrevButton variant={buttonVariant} />
<Calendar.NextButton variant={buttonVariant} />
</Calendar.Nav>
{#each months as month, monthIndex (month)}
<Calendar.Month>
<Calendar.Header>
<Calendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow class="select-none">
{#each weekdays as weekday (weekday)}
<Calendar.HeadCell>
{weekday.slice(0, 2)}
</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<Calendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<Calendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value)
})}
{:else}
<Calendar.Day />
{/if}
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar.Month>
{/each}
</Calendar.Months>
{/snippet}
</CalendarPrimitive.Root>
@@ -0,0 +1,40 @@
import Root from "./calendar.svelte";
import Cell from "./calendar-cell.svelte";
import Day from "./calendar-day.svelte";
import Grid from "./calendar-grid.svelte";
import Header from "./calendar-header.svelte";
import Months from "./calendar-months.svelte";
import GridRow from "./calendar-grid-row.svelte";
import Heading from "./calendar-heading.svelte";
import GridBody from "./calendar-grid-body.svelte";
import GridHead from "./calendar-grid-head.svelte";
import HeadCell from "./calendar-head-cell.svelte";
import NextButton from "./calendar-next-button.svelte";
import PrevButton from "./calendar-prev-button.svelte";
import MonthSelect from "./calendar-month-select.svelte";
import YearSelect from "./calendar-year-select.svelte";
import Month from "./calendar-month.svelte";
import Nav from "./calendar-nav.svelte";
import Caption from "./calendar-caption.svelte";
export {
Day,
Cell,
Grid,
Header,
Months,
GridRow,
Heading,
GridBody,
GridHead,
HeadCell,
NextButton,
PrevButton,
Nav,
Month,
YearSelect,
MonthSelect,
Caption,
//
Root as Calendar,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn('flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
@@ -0,0 +1,43 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg font-semibold leading-none", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
@@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from "bits-ui";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import * as Button from "$lib/components/ui/button/index.js";
let { ref = $bindable(null), ...restProps }: Button.Props = $props();
</script>
<Button.Root bind:ref type="submit" {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChild<FormPrimitive.DescriptionProps> = $props();
</script>
<FormPrimitive.Description
bind:ref
data-slot="form-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
import * as FormPrimitive from "formsnap";
import type { FormPathLeaves } from "sveltekit-superforms";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
form,
name,
children: childrenProp,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> &
FormPrimitive.ElementFieldProps<T, U> = $props();
</script>
<FormPrimitive.ElementField {form} {name}>
{#snippet children({ constraints, errors, tainted, value })}
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div>
{/snippet}
</FormPrimitive.ElementField>
@@ -0,0 +1,30 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
errorClasses,
children: childrenProp,
...restProps
}: WithoutChild<FormPrimitive.FieldErrorsProps> & {
errorClasses?: string | undefined | null;
} = $props();
</script>
<FormPrimitive.FieldErrors
bind:ref
class={cn("text-destructive text-sm font-medium", className)}
{...restProps}
>
{#snippet children({ errors, errorProps })}
{#if childrenProp}
{@render childrenProp({ errors, errorProps })}
{:else}
{#each errors as error (error)}
<div {...errorProps} class={cn(errorClasses)}>{error}</div>
{/each}
{/if}
{/snippet}
</FormPrimitive.FieldErrors>
@@ -0,0 +1,29 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap";
import type { FormPath } from "sveltekit-superforms";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
form,
name,
children: childrenProp,
...restProps
}: FormPrimitive.FieldProps<T, U> &
WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<FormPrimitive.Field {form} {name}>
{#snippet children({ constraints, errors, tainted, value })}
<div
bind:this={ref}
data-slot="form-item"
class={cn("space-y-2", className)}
{...restProps}
>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div>
{/snippet}
</FormPrimitive.Field>
@@ -0,0 +1,15 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap";
import type { FormPath } from "sveltekit-superforms";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
form,
name,
...restProps
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
</script>
<FormPrimitive.Fieldset bind:ref {form} {name} class={cn("space-y-2", className)} {...restProps} />
@@ -0,0 +1,24 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { Label } from "$lib/components/ui/label/index.js";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
children,
class: className,
...restProps
}: WithoutChild<FormPrimitive.LabelProps> = $props();
</script>
<FormPrimitive.Label {...restProps} bind:ref>
{#snippet child({ props })}
<Label
{...props}
data-slot="form-label"
class={cn("data-[fs-error]:text-destructive", className)}
>
{@render children?.()}
</Label>
{/snippet}
</FormPrimitive.Label>
@@ -0,0 +1,16 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChild<FormPrimitive.LegendProps> = $props();
</script>
<FormPrimitive.Legend
bind:ref
class={cn("data-[fs-error]:text-destructive text-sm font-medium leading-none", className)}
{...restProps}
/>
@@ -0,0 +1,33 @@
import * as FormPrimitive from "formsnap";
import Description from "./form-description.svelte";
import Label from "./form-label.svelte";
import FieldErrors from "./form-field-errors.svelte";
import Field from "./form-field.svelte";
import Fieldset from "./form-fieldset.svelte";
import Legend from "./form-legend.svelte";
import ElementField from "./form-element-field.svelte";
import Button from "./form-button.svelte";
const Control = FormPrimitive.Control;
export {
Field,
Control,
Label,
Button,
FieldErrors,
Description,
Fieldset,
Legend,
ElementField,
//
Field as FormField,
Control as FormControl,
Description as FormDescription,
Label as FormLabel,
FieldErrors as FormFieldErrors,
Fieldset as FormFieldset,
Legend as FormLegend,
ElementField as FormElementField,
Button as FormButton,
};
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot="input"
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot="input"
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className
)}
{...restProps}
/>
@@ -0,0 +1,37 @@
import { Select as SelectPrimitive } from "bits-ui";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
const Root = SelectPrimitive.Root;
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
};
@@ -0,0 +1,40 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot="separator"
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>
@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";
@@ -0,0 +1,13 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
/>
@@ -0,0 +1,28 @@
import Root from "./table.svelte";
import Body from "./table-body.svelte";
import Caption from "./table-caption.svelte";
import Cell from "./table-cell.svelte";
import Footer from "./table-footer.svelte";
import Head from "./table-head.svelte";
import Header from "./table-header.svelte";
import Row from "./table-row.svelte";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tbody
bind:this={ref}
data-slot="table-body"
class={cn("[&_tr:last-child]:border-0", className)}
{...restProps}
>
{@render children?.()}
</tbody>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption
bind:this={ref}
data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps}
>
{@render children?.()}
</caption>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLTdAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
</script>
<td
bind:this={ref}
data-slot="table-cell"
class={cn(
"whitespace-nowrap bg-clip-padding p-2 align-middle [&:has([role=checkbox])]:pr-0",
className
)}
{...restProps}
>
{@render children?.()}
</td>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tfoot
bind:this={ref}
data-slot="table-footer"
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps}
>
{@render children?.()}
</tfoot>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLThAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
</script>
<th
bind:this={ref}
data-slot="table-head"
class={cn(
"text-foreground h-10 whitespace-nowrap bg-clip-padding px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
className
)}
{...restProps}
>
{@render children?.()}
</th>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<thead
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
{...restProps}
>
{@render children?.()}
</thead>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script>
<tr
bind:this={ref}
data-slot="table-row"
class={cn(
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...restProps}
>
{@render children?.()}
</tr>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
</script>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table
bind:this={ref}
data-slot="table"
class={cn("w-full caption-bottom text-sm", className)}
{...restProps}
>
{@render children?.()}
</table>
</div>
@@ -0,0 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
};
@@ -0,0 +1,22 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot="textarea"
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
bind:value
{...restProps}
></textarea>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+202
View File
@@ -0,0 +1,202 @@
// src/lib/stores/auth.svelte.ts
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest';
export interface DecodedToken {
user_id: string;
role: UserRole;
exp: number;
}
export interface User {
id: string;
email: string;
role: UserRole;
firstName: string;
lastName: string;
phone?: string;
dateOfBirth?: string;
loyaltyStamps?: number;
referralCode?: string;
profilePicUrl?: string;
}
class AuthStore {
private token = $state<string | null>(null);
private user = $state<User | null>(null);
private loading = $state(true);
constructor() {
if (browser) {
this.initializeAuth();
}
}
get isAuthenticated() {
return this.token !== null && this.user !== null;
}
get currentUser() {
return this.user;
}
get currentToken() {
return this.token;
}
get isLoading() {
return this.loading;
}
private initializeAuth() {
const storedToken = localStorage.getItem('authToken');
if (storedToken) {
const decoded = this.decodeToken(storedToken);
if (decoded && !this.isTokenExpired(decoded)) {
this.token = storedToken;
// Set basic user info from token
this.user = {
id: decoded.user_id,
role: decoded.role,
email: '',
firstName: '',
lastName: ''
};
this.fetchUserProfile();
} else {
this.clearAuth();
}
}
this.loading = false;
}
private decodeToken(token: string): DecodedToken | null {
try {
const payload = token.split('.')[1];
const decoded = JSON.parse(atob(payload));
return decoded;
} catch (e) {
console.error('Failed to decode token:', e);
return null;
}
}
private isTokenExpired(decoded: DecodedToken): boolean {
return decoded.exp * 1000 < Date.now();
}
// Simple setters - UI handles the API calls
setToken(token: string) {
this.token = token;
if (browser) {
localStorage.setItem('authToken', token);
}
// Decode to get basic info
const decoded = this.decodeToken(token);
if (decoded) {
this.user = {
id: decoded.user_id,
role: decoded.role,
email: '',
firstName: '',
lastName: ''
};
this.fetchUserProfile();
}
}
private async fetchUserProfile() {
if (!this.token) return;
try {
const response = await fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch profile');
}
const userData = await response.json();
this.user = userData;
} catch (error) {
console.error('Failed to fetch user profile:', error);
this.clearAuth();
}
}
// inside AuthStore
logout = () => {
this.clearAuth();
goto('/');
};
private clearAuth() {
this.token = null;
this.user = null;
if (browser) {
localStorage.removeItem('authToken');
}
}
hasRole(requiredRole: UserRole | UserRole[]): boolean {
if (!this.user) return false;
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
return roles.includes(this.user.role);
}
isAdmin(): boolean {
return this.hasRole('admin');
}
isVerified(): boolean {
return this.hasRole(['verified_email', 'admin']);
}
// Refresh token before it expires
async refreshTokenIfNeeded() {
if (!this.token) return;
const decoded = this.decodeToken(this.token);
if (!decoded) {
this.clearAuth();
return;
}
// Refresh if token expires in less than 2 weeks
const threeDays = 2 * 7 * 24 * 60 * 60 * 1000;
if (decoded.exp * 1000 - Date.now() < threeDays) {
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (response.ok) {
const data = await response.json();
this.setToken(data.token);
} else {
this.clearAuth();
}
} catch (error) {
console.error('Token refresh failed:', error);
}
}
}
// Manual refresh method
async refreshProfile() {
await this.fetchUserProfile();
}
}
export const authStore = new AuthStore();
+13
View File
@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
export let error: Error;
export let status: number;
</script>
<section class="py-20 text-center">
<h1 class="mb-4 text-5xl font-bold">{status}</h1>
<p class="mb-6 text-lg text-gray-600">
{status === 404
? "Oops! The page you are looking for doesn't exist."
: 'Something went wrong. Please try again later.'}
</p>
{#if error?.message}
<p class="mb-8 text-gray-500">{error.message}</p>
{/if}
<Button href="/" class="px-6 py-3 text-lg">Go Back Home</Button>
</section>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import NavBar from '$lib/components/layout/NavBar.svelte';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
// Verify email address
async function verify_email() {
alert('Verifying email address...');
const response = await fetch('/api/verify-email', {
method: 'POST'
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to verify email address');
}
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<NavBar />
<Toaster position="bottom-center" />
<main class="pt-16">
{#if authStore.currentUser?.role === 'unverified_email'}
<div class="w-full bg-red-600 py-2 pl-8 pr-8 text-center text-sm font-medium text-white">
Please verify your email address to continue. Didn't recieve the email? Check your spam
folder, or <button
type="button"
onclick={verify_email}
class="cursor-pointer border-0 bg-transparent p-0 text-white underline"
>
click here
</button>
to resend it.
</div>
{/if}
{@render children?.()}
</main>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import PortfolioCarousel from '$lib/components/layout/PortfolioCarousel.svelte';
</script>
<section class="py-20 text-center">
<h1 class="m-2 mb-4 text-4xl font-bold">Welcome to Crussell Nails</h1>
<p class="mb-8 text-lg text-gray-600">
Professional beauty treatments in a calm and friendly environment.
</p>
<Button href="/book" class="px-6 py-3 text-lg">Book an Appointment</Button>
</section>
<section class="mx-auto max-w-4xl px-6 py-16">
<h2 class="mb-8 text-center text-2xl font-semibold">Our Services</h2>
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Hands</h3>
<p class="text-gray-600">
Lorem hand ipsum nail dolor art sit paint amet, skin consectetur cuticle adipiscing file
elit.
</p>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Feet</h3>
<p class="text-gray-600">
Lorem foot ipsum nail dolor art sit paint amet, skin consectetur cuticle adipiscing file
elit.
</p>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Brows</h3>
<p class="text-gray-600">
Lorem brow ipsum eye dolor art sit shape amet, tint consectetur colour adipiscing tweaze
elit.
</p>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Wax</h3>
<p class="text-gray-600">
Lorem wax ipsum leg dolor body sit sticky amet, back consectetur sack adipiscing crack elit.
</p>
</div>
</div>
</section>
<PortfolioCarousel />
<section class="bg-gray-50 py-16">
<div class="mx-auto max-w-3xl px-6 text-center">
<h2 class="mb-4 text-2xl font-semibold">Why Choose Us?</h2>
<p class="leading-relaxed text-gray-600">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus
tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices
diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci
nec nonummy molestie,
</p>
</div>
</section>
<section class="py-20 text-center">
<h2 class="mb-4 text-2xl font-semibold">Ready to Treat Yourself?</h2>
<Button class="px-6 py-3 text-lg" href="prices">View Price List</Button>
</section>
@@ -0,0 +1,40 @@
// src/routes/api/[...path]/+server.ts
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
async function proxyRequest(request: Request, path: string) {
const url = `${BACKEND_URL}/api/${path}`;
try {
const headers = new Headers(request.headers);
headers.delete('host');
const backendRes = await fetch(url, {
method: request.method,
headers,
body: ['GET', 'HEAD'].includes(request.method)
? undefined
: await request.text()
});
// Forward everything transparently
const resHeaders = new Headers(backendRes.headers);
return new Response(backendRes.body, {
status: backendRes.status,
headers: resHeaders
});
} catch (err) {
console.error('Proxy error:', err);
return error(502, 'Backend unreachable');
}
}
export const GET: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
export const POST: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
export const PUT: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
export const DELETE: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
export const PATCH: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
+535
View File
@@ -0,0 +1,535 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import Calendar from '$lib/components/ui/calendar/calendar.svelte';
import { CalendarDate, getLocalTimeZone } from '@internationalized/date';
// Booking state
let currentStep = $state<number>(1);
let selectedServices = $state<any[]>([]);
let selectedDate = $state<CalendarDate | undefined>(new CalendarDate(2025, 6, 12));
let selectedTime = $state<string | null>(null);
let customerInfo = $state({
firstName: '',
lastName: '',
email: '',
phone: '',
specialRequests: ''
});
// Service options
const services = [
{
id: 'manicure',
name: 'Classic Manicure',
duration: 45,
price: 25,
description: 'Shape, cuticle care, polish'
},
{
id: 'gel-manicure',
name: 'Gel Manicure',
duration: 60,
price: 35,
description: 'Long-lasting gel polish application'
},
{
id: 'pedicure',
name: 'Classic Pedicure',
duration: 60,
price: 30,
description: 'Soak, exfoliate, shape, polish'
},
{
id: 'gel-pedicure',
name: 'Gel Pedicure',
duration: 75,
price: 40,
description: 'Premium pedicure with gel polish'
},
{
id: 'eyebrow-shape',
name: 'Eyebrow Shaping',
duration: 30,
price: 15,
description: 'Wax and tweeze to perfect shape'
},
{
id: 'eyebrow-tint',
name: 'Eyebrow Tint & Shape',
duration: 45,
price: 25,
description: 'Shape and tint for definition'
},
{
id: 'leg-wax',
name: 'Full Leg Wax',
duration: 60,
price: 35,
description: 'Smooth legs from ankle to hip'
},
{
id: 'bikini-wax',
name: 'Bikini Wax',
duration: 30,
price: 20,
description: 'Clean bikini line waxing'
}
];
// Mock unavailable dates
const bookedDates = Array.from({ length: 5 }, (_, i) => new CalendarDate(2025, 6, 17 + i));
function generateTimeSlots(duration: number) {
const slots = [];
const startHour = 9;
const endHour = 17;
for (let hour = startHour; hour < endHour; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
// Changed to 15-minute intervals
const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
// Check if there's enough time before closing
const endTime = new Date();
endTime.setHours(hour, minute + duration, 0, 0);
if (endTime.getHours() <= endHour) {
slots.push(timeStr);
}
}
}
return slots;
}
// Generate time slots based on selected services (use longest duration)
const timeSlots = $derived(
selectedServices.length > 0 ? generateTimeSlots(getTotalDuration()) : []
);
function getTotalDuration() {
return selectedServices.reduce((total, service) => total + service.duration, 0);
}
function getTotalPrice() {
return selectedServices.reduce((total, service) => total + service.price, 0);
}
function toggleService(service: any) {
const index = selectedServices.findIndex((s) => s.id === service.id);
if (index >= 0) {
selectedServices = selectedServices.filter((s) => s.id !== service.id);
} else {
selectedServices = [...selectedServices, service];
}
}
function isServiceSelected(service: any) {
return selectedServices.some((s) => s.id === service.id);
}
function isTimeInDuration(
time: string,
selectedTime: string | null,
totalDuration: number
): 'start' | 'duration' | 'none' {
if (!selectedTime) return 'none';
if (time === selectedTime) return 'start';
const [selectedHour, selectedMin] = selectedTime.split(':').map(Number);
const [currentHour, currentMin] = time.split(':').map(Number);
const selectedMinutes = selectedHour * 60 + selectedMin;
const currentMinutes = currentHour * 60 + currentMin;
const endMinutes = selectedMinutes + totalDuration;
if (currentMinutes > selectedMinutes && currentMinutes < endMinutes) {
return 'duration';
}
return 'none';
}
function nextStep() {
if (currentStep < 4) {
currentStep++;
// Use setTimeout to ensure DOM is updated before scrolling
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
}
}
function prevStep() {
if (currentStep > 1) {
currentStep--;
// Use setTimeout to ensure DOM is updated before scrolling
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
}
}
function handleBooking() {
// This would connect to your backend
alert('Booking submitted! (This is just a prototype - no backend connected yet)');
}
const canProceedStep1 = $derived(selectedServices.length > 0);
const canProceedStep2 = $derived(selectedDate && selectedTime);
const canProceedStep3 = $derived(
customerInfo.firstName && customerInfo.lastName && customerInfo.email && customerInfo.phone
);
</script>
<div class="mx-auto max-w-4xl p-6">
<div class="mb-8 text-center">
<h1 class="mb-2 text-3xl font-bold">Book Your Appointment</h1>
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
</div>
<!-- Progress Indicator -->
<div class="mb-8 grid grid-cols-2 gap-4 md:flex md:items-center md:justify-center md:space-x-4">
{#each ['Service', 'Date & Time', 'Details', 'Payment'] as step, index}
<div class="flex items-center justify-start md:justify-center">
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium {index +
1 <=
currentStep
? 'bg-primary text-primary-foreground'
: 'bg-gray-200 text-gray-600'}"
>
{index + 1}
</div>
<span
class="ml-2 text-sm font-medium {index + 1 <= currentStep
? 'text-primary'
: 'text-gray-600'}"
>
{step}
</span>
{#if index < 3}
<div
class="mx-4 hidden h-0.5 w-8 md:block {index + 1 < currentStep
? 'bg-primary'
: 'bg-gray-200'}"
></div>
{/if}
</div>
{/each}
</div>
<!-- Step 1: Service Selection -->
{#if currentStep === 1}
<Card.Root>
<Card.Header>
<Card.Title>Choose Your Services</Card.Title>
<Card.Description>Select one or more treatments for your appointment</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
{#each services as service}
<button
type="button"
class="focus:ring-primary cursor-pointer rounded-lg p-4 text-left shadow-sm transition-colors hover:bg-fuchsia-50 {isServiceSelected(
service
)
? 'bg-fuchsia-100'
: 'border-ring'}"
onclick={() => toggleService(service)}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description}</p>
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500">
<span>{service.duration} mins</span>
<span>£{service.price}</span>
</div>
</div>
<div
class="ml-3 flex h-5 w-5 items-center justify-center rounded border-2 {isServiceSelected(
service
)
? 'border-primary bg-primary'
: 'border-gray-300'}"
aria-hidden="true"
>
{#if isServiceSelected(service)}
<svg class="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</div>
</div>
</button>
{/each}
</div>
{#if selectedServices.length > 0}
<div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold">Selected Services</h4>
<div class="space-y-2">
{#each selectedServices as service}
<div class="flex justify-between text-sm">
<span>{service.name}</span>
<span>{service.duration} mins • £{service.price}</span>
</div>
{/each}
<Separator class="my-2" />
<div class="flex justify-between text-sm font-semibold">
<span>Total Duration:</span>
<span>{getTotalDuration()} minutes</span>
</div>
<div class="flex justify-between text-sm font-semibold">
<span>Total Cost:</span>
<span>£{getTotalPrice()}</span>
</div>
</div>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-end">
<Button disabled={!canProceedStep1} onclick={nextStep}>Next: Select Date & Time</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 2: Date & Time Selection -->
{#if currentStep === 2}
<Card.Root>
<Card.Header>
<Card.Title>Choose Date & Time</Card.Title>
<Card.Description>
{selectedServices.map((s) => s.name).join(', ')}{getTotalDuration()} minutes total • £{getTotalPrice()}
</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<Card.Root class="gap-0 border-0 p-0">
<Card.Content class="relative p-0 md:pr-48">
<div class="flex items-center justify-center p-6">
<Calendar
type="single"
bind:value={selectedDate}
isDateUnavailable={(date) => bookedDates.some((d) => d.compare(date) === 0)}
class="data-unavailable:line-through data-unavailable:opacity-100 bg-transparent p-0 [--cell-size:--spacing(10)] md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:hidden"
weekdayFormat="short"
/>
</div>
<div
class="no-scrollbar inset-y-0 right-0 flex max-h-48 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-l md:border-t-0"
>
<div class="grid gap-2">
{#if timeSlots.length > 0}
{#each timeSlots as time (time)}
{@const timeStatus = isTimeInDuration(time, selectedTime, getTotalDuration())}
<Button
variant="outline"
onclick={() => (selectedTime = time)}
class={`w-full hover:bg-fuchsia-50 ${
timeStatus === 'start'
? ' text-primary bg-fuchsia-200'
: timeStatus === 'duration'
? ' text-primary bg-fuchsia-100'
: ''
}`}
>
{time}
{#if timeStatus === 'duration'}
<span class="ml-1 text-xs opacity-70">(selected)</span>
{/if}
</Button>
{/each}
{:else if selectedServices.length === 0}
<p class="text-center text-sm text-gray-500">Select services first</p>
{:else}
<p class="text-center text-sm text-gray-500">No available slots</p>
{/if}
</div>
</div>
</Card.Content>
</Card.Root>
</Card.Content>
<!-- Mobile appointment summary - shown only on mobile -->
<div class="border-t px-6 py-4 text-center text-sm sm:hidden">
{#if selectedDate && selectedTime}
Appointment for
<span class="font-medium">
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'short'
})}
</span>
at <span class="font-medium">{selectedTime}</span>
{:else}
Select a date and time
{/if}
</div>
<Card.Footer class="flex justify-between border-t !py-5 px-6">
<Button variant="outline" onclick={prevStep}>Back</Button>
<div class="flex items-center space-x-4">
<!-- Desktop appointment summary - hidden on mobile -->
<div class="hidden text-sm sm:block">
{#if selectedDate && selectedTime}
Appointment for
<span class="font-medium">
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'short'
})}
</span>
at <span class="font-medium">{selectedTime}</span>
{:else}
Select a date and time
{/if}
</div>
<Button disabled={!canProceedStep2} onclick={nextStep}>Next: Your Details</Button>
</div>
</Card.Footer>
</Card.Root>
{/if}
<!-- Step 3: Customer Details -->
{#if currentStep === 3}
<Card.Root>
<Card.Header>
<Card.Title>Your Details</Card.Title>
<Card.Description>Please provide your contact information</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<!-- Booking Summary -->
<div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold">Booking Summary</h4>
<div class="space-y-1 text-sm">
<div>
<span class="font-medium">Services:</span>
<div class="ml-4 mt-1 space-y-1">
{#each selectedServices as service}
<div class="flex justify-between">
<span>{service.name}</span>
<span>£{service.price}</span>
</div>
{/each}
</div>
</div>
<div class="flex justify-between">
<span>Date:</span>
<span class="font-medium">
{selectedDate?.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'long'
})}
</span>
</div>
<div class="flex justify-between">
<span>Time:</span>
<span class="font-medium">{selectedTime}</span>
</div>
<div class="flex justify-between">
<span>Total Duration:</span>
<span class="font-medium">{getTotalDuration()} minutes</span>
</div>
<Separator class="my-2" />
<div class="flex justify-between font-semibold">
<span>Total Cost:</span>
<span>£{getTotalPrice()}</span>
</div>
</div>
</div>
<!-- Contact Form -->
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="firstName">First Name *</Label>
<Input
id="firstName"
bind:value={customerInfo.firstName}
placeholder="Enter your first name"
/>
</div>
<div class="space-y-2">
<Label for="lastName">Last Name *</Label>
<Input
id="lastName"
bind:value={customerInfo.lastName}
placeholder="Enter your last name"
/>
</div>
<div class="space-y-2">
<Label for="email">Email *</Label>
<Input
id="email"
type="email"
bind:value={customerInfo.email}
placeholder="Enter your email"
/>
</div>
<div class="space-y-2">
<Label for="phone">Phone Number *</Label>
<Input
id="phone"
type="tel"
bind:value={customerInfo.phone}
placeholder="Enter your phone number"
/>
</div>
</div>
<div class="space-y-2">
<Label for="requests">Special Requests (Optional)</Label>
<Textarea
id="requests"
bind:value={customerInfo.specialRequests}
placeholder="Any allergies, preferences, or special requirements..."
rows={3}
/>
</div>
<div class="text-sm text-gray-600">
<p>* Required fields</p>
<p class="mt-2">
By booking, you agree to our Terms & Conditions and Privacy Policy. We'll send you
appointment reminders via email and SMS.
</p>
</div>
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={prevStep}>Back</Button>
<Button
disabled={!canProceedStep3}
onclick={nextStep}
class="bg-primary text-primary-foreground hover:bg-primary/90"
>
Next: Payment
</Button>
</Card.Footer>
</Card.Root>
{/if}
{#if currentStep === 4}
<!-- Square Payment Window -->
<!-- TODO: Add payment form -->
<div class="rounded-lg bg-white p-6">
<h2 class="mb-4 text-2xl font-semibold">Payment Confirmation</h2>
<p class="text-gray-600">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus
tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices
diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci
nec nonummy molestie,
</p>
</div>
{/if}
</div>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import ContactCard from '$lib/components/layout/ContactCard.svelte';
</script>
<section class="py-12">
<h1 class="mb-8 text-center text-2xl font-semibold">Contact Me</h1>
<ContactCard
name="Chelsea Russell"
role="Owner / Beauty Specialist"
phone="+44 8008135"
email="chelsea@emailaddress.com"
instagram="crussell"
address="Business Centre, Office Street, Work"
/>
</section>
+529
View File
@@ -0,0 +1,529 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import RequiredLabel from '$lib/components/layout/RequiredLabel.svelte';
// zxcvbn-ts imports
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
import * as languageCommon from '@zxcvbn-ts/language-common';
import * as languageEn from '@zxcvbn-ts/language-en';
import { toast } from 'svelte-sonner';
// set up options so that feedback, dictionary etc. are included
zxcvbnOptions.setOptions({
translations: languageEn.translations,
graphs: languageCommon.adjacencyGraphs,
dictionary: {
...languageCommon.dictionary,
...languageEn.dictionary
}
});
let isLogin = $state(true);
let agreedToPolicy = $state(false);
let formData = $state({
email: '',
password: '',
firstName: '',
lastName: '',
confirmPassword: '',
phone: '',
dateOfBirth: ''
});
// Validation state
let validationErrors = $state({
email: '',
phone: '',
dateOfBirth: ''
});
function toggleMode() {
isLogin = !isLogin;
agreedToPolicy = false;
formData = {
email: '',
password: '',
firstName: '',
lastName: '',
confirmPassword: '',
phone: '',
dateOfBirth: ''
};
validationErrors = {
email: '',
phone: '',
dateOfBirth: ''
};
}
// Email validation
function validateEmail(email: string): boolean {
// ASCII-only email regex (safe with most providers)
const asciiRegex = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
// Unicode-friendly regex (valid per RFC 6531)
const unicodeRegex = /^[\p{L}\p{M}0-9._%+\-]+@[\p{L}\p{M}0-9.\-]+\.[\p{L}\p{M}]{2,}$/u;
if (!email) {
validationErrors.email = '';
return true;
}
if (asciiRegex.test(email)) {
// OK: standard ASCII address
validationErrors.email = '';
return true;
} else if (unicodeRegex.test(email)) {
// Looks like a valid internationalised address, but unsupported
validationErrors.email =
'This looks like a valid international email address, ' +
'but our providers only supports standard (A-Z, 0-9) email addresses. ' +
'We are sorry. Please use an ASCII-compatible email.';
return false;
} else {
// Not even valid under the Unicode spec
validationErrors.email = 'Invalid email format';
return false;
}
}
// Phone validation (UK format)
function validatePhone(phone: string): boolean {
if (!phone) {
validationErrors.phone = '';
return true;
}
// Remove spaces, hyphens, parentheses for validation
const cleanPhone = phone.replace(/[\s\-()]/g, '');
// UK phone regex: +44 followed by 10-11 digits, or 0 followed by 10-11 digits
const phoneRegex = /^(\+44[1-9]\d{9,10}|0[1-9]\d{9,10})$/;
const isValid = phoneRegex.test(cleanPhone);
validationErrors.phone = isValid ? '' : 'Invalid UK phone number';
return isValid;
}
// Format phone number as user types
function formatPhoneInput(value: string): string {
// Remove all non-digit and non-plus characters
let cleaned = value.replace(/[^\d+]/g, '');
// If it starts with +44, format as +44 XXXX XXXXXX
if (cleaned.startsWith('+44')) {
const digits = cleaned.slice(3);
if (digits.length <= 4) return `+44 ${digits}`;
if (digits.length <= 10) return `+44 ${digits.slice(0, 4)} ${digits.slice(4)}`;
return `+44 ${digits.slice(0, 4)} ${digits.slice(4, 10)}`;
}
// If it starts with 0, format as 0XXXX XXXXXX
if (cleaned.startsWith('0')) {
if (cleaned.length <= 5) return cleaned;
return `${cleaned.slice(0, 5)} ${cleaned.slice(5, 11)}`;
}
return cleaned;
}
// Handle phone input
function handlePhoneInput(e: Event) {
const target = e.target as HTMLInputElement;
const cursorPos = target.selectionStart || 0;
const oldLength = formData.phone.length;
formData.phone = formatPhoneInput(target.value);
// Adjust cursor position after formatting
const newLength = formData.phone.length;
const diff = newLength - oldLength;
requestAnimationFrame(() => {
target.setSelectionRange(cursorPos + diff, cursorPos + diff);
});
}
// Age validation (must be 16+)
function validateAge(dateStr: string): boolean {
if (!dateStr) {
validationErrors.dateOfBirth = '';
return true;
}
const dob = new Date(dateStr);
const today = new Date();
const sixteenYearsAgo = new Date(today.getFullYear() - 16, today.getMonth(), today.getDate());
const isValid = dob <= sixteenYearsAgo;
validationErrors.dateOfBirth = isValid
? ''
: "You must be at least 16 years old to register, but you can call for an appointment by clicking 'Contact' at the top of the page.";
return isValid;
}
// Normalize data before sending
function normalizeFormData() {
return {
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
email: formData.email.trim().toLowerCase(),
password: formData.password,
phone: formData.phone.replace(/[\s\-()]/g, ''), // Remove formatting
dateOfBirth: formData.dateOfBirth.trim(),
agreedToPolicy: agreedToPolicy
};
}
async function handleSubmit() {
if (isLogin) {
// Login flow with loading toast
const loadingToast = toast.loading('Signing in...');
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email.trim().toLowerCase(),
password: formData.password
})
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('authToken', data.token);
toast.success('Successfully logged in!', { id: loadingToast });
window.location.href = '/';
} else if (response.status === 409) {
toast.error('Login already in progress. Please wait.', { id: loadingToast });
} else if (response.status === 401) {
toast.error('Invalid email or password.', { id: loadingToast });
} else {
const text = await response.text();
toast.error('Error: ' + text, { id: loadingToast });
}
} catch (err) {
console.error(err);
toast.error('Network error, please try again later.', { id: loadingToast });
}
return;
}
// Validate before submitting
const isEmailValid = validateEmail(formData.email);
const isPhoneValid = validatePhone(formData.phone);
const isAgeValid = validateAge(formData.dateOfBirth);
if (!isEmailValid || !isPhoneValid || !isAgeValid) {
toast.error('Please fix the validation errors before submitting.');
return;
}
// Registration flow with loading toast
const loadingToast = toast.loading('Creating your account...');
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(normalizeFormData())
});
if (response.ok) {
toast.success('Account created successfully!', { id: loadingToast });
toggleMode();
} else {
const text = await response.text();
toast.error('Error: ' + text, { id: loadingToast });
}
} catch (err) {
console.error(err);
toast.error('Network error, please try again later.', { id: loadingToast });
}
}
function handleSocialLogin(provider: string) {
alert(`${provider} login clicked! (This is just a prototype)`);
}
// when password changes, re-compute strength
let passwordStrength = $derived(formData.password ? zxcvbn(formData.password) : null);
// form completion check
let isFormComplete = $derived(
isLogin ||
(formData.firstName.trim() &&
formData.lastName.trim() &&
formData.phone &&
formData.dateOfBirth &&
formData.email &&
formData.password &&
formData.confirmPassword &&
formData.password === formData.confirmPassword &&
passwordStrength &&
passwordStrength.score >= 2 &&
agreedToPolicy &&
!validationErrors.email &&
!validationErrors.phone &&
!validationErrors.dateOfBirth)
);
</script>
<div class="mx-auto flex w-full justify-center p-4 pt-2">
<div class="w-full max-w-md space-y-6">
<!-- Header -->
<div class="space-y-2 text-center">
<h1 class="text-2xl font-bold">
{isLogin ? 'Welcome back' : 'Create account'}
</h1>
<p class="text-muted-foreground">
{isLogin
? 'Sign in to your account to continue'
: 'Choose an option below to create your account'}
</p>
</div>
<!-- Social Login Buttons -->
<div class="space-y-3">
<Button variant="outline" onclick={() => handleSocialLogin('Google')} class="w-full">
<svg class="mr-2" style="width: 1rem; height: 1rem;" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<Button variant="outline" onclick={() => handleSocialLogin('Microsoft')} class="w-full">
<svg
class="mr-2"
style="width: 1rem; height: 1rem;"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zM24 11.4H12.6V0H24v11.4z"
/>
</svg>
Continue with Microsoft
</Button>
<Button variant="outline" onclick={() => handleSocialLogin('Facebook')} class="w-full">
<svg
class="mr-2"
style="width: 1rem; height: 1rem;"
fill="currentColor"
viewBox="0 0 24 24"
>
<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>
Continue with Facebook
</Button>
</div>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<Separator class="w-full" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background text-muted-foreground px-2">or continue with email</span>
</div>
</div>
<!-- Email/Password Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
{#if !isLogin}
<!-- Registration Fields -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<RequiredLabel forId="firstName" text="First Name" />
<Input
id="firstName"
placeholder="John"
bind:value={formData.firstName}
onblur={() => (formData.firstName = formData.firstName.trim())}
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="lastName" text="Last Name" />
<Input
id="lastName"
placeholder="Doe"
bind:value={formData.lastName}
onblur={() => (formData.lastName = formData.lastName.trim())}
required
/>
</div>
</div>
<div class="space-y-2">
<RequiredLabel forId="phone" text="Phone Number" />
<Input
id="phone"
type="tel"
placeholder="07123 456789 or +44 7123 456789"
value={formData.phone}
oninput={handlePhoneInput}
onblur={() => validatePhone(formData.phone)}
required
/>
{#if validationErrors.phone}
<p class="text-sm text-red-500">{validationErrors.phone}</p>
{/if}
</div>
<div class="space-y-2">
<RequiredLabel forId="dateOfBirth" text="Date of Birth" />
<Input
id="dateOfBirth"
type="date"
bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)}
max={new Date(new Date().setFullYear(new Date().getFullYear() - 16))
.toISOString()
.split('T')[0]}
required
/>
{#if validationErrors.dateOfBirth}
<p class="text-sm text-red-500">{validationErrors.dateOfBirth}</p>
{/if}
</div>
{/if}
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<Input
id="email"
type="email"
placeholder="john@example.com"
bind:value={formData.email}
onblur={() => validateEmail(formData.email)}
required
/>
{#if validationErrors.email}
<p class="text-sm text-red-500">{validationErrors.email}</p>
{/if}
</div>
<div class="space-y-2">
<RequiredLabel forId="password" text="Password" />
<Input
id="password"
type="password"
placeholder="Enter your password"
bind:value={formData.password}
required
/>
{#if !isLogin && passwordStrength}
<!-- Strength meter bar -->
<div class="mt-1">
<div class="h-2 overflow-hidden rounded bg-gray-200">
<div
class="h-2 transition-all"
style="
width: {(passwordStrength.score + 1) * 20}%;
background-color: {passwordStrength.score < 2
? 'var(--chart-1)'
: passwordStrength.score === 2
? 'orange'
: passwordStrength.score === 3
? 'var(--chart-2)'
: 'var(--chart-2)'};
"
></div>
</div>
<p class="mt-1 text-xs">
{passwordStrength.feedback.warning
? passwordStrength.feedback.warning
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][passwordStrength.score]}`}
</p>
{#if passwordStrength.feedback.suggestions.length > 0}
<ul class="text-muted-foreground ml-4 mt-1 list-disc text-xs">
{#each passwordStrength.feedback.suggestions as suggestion}
<li>{suggestion}</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{#if !isLogin}
<div class="space-y-2">
<RequiredLabel forId="confirmPassword" text="Confirm Password" />
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
bind:value={formData.confirmPassword}
required
/>
{#if formData.confirmPassword && formData.password !== formData.confirmPassword}
<p class="mt-1 text-sm text-red-500">Passwords do not match</p>
{/if}
</div>
<div class="flex items-center space-x-2">
<Checkbox id="privacy" bind:checked={agreedToPolicy} class="mt-1" />
<div class="text-sm leading-snug">
<Label for="privacy">
I agree to the
<a
href="/terms"
class="text-primary font-semibold hover:underline"
target="_blank"
rel="noopener noreferrer">Terms & Conditions</a
>
and
<a
href="/privacy"
class="text-primary font-semibold hover:underline"
target="_blank"
rel="noopener noreferrer">Privacy Policy</a
>
</Label>
</div>
</div>
{/if}
<Button type="submit" class="w-full" disabled={!isFormComplete}>
{isLogin ? 'Sign In' : 'Create Account'}
</Button>
</form>
<div class="text-muted-foreground text-center text-sm">
{isLogin ? "Don't have an account?" : 'Already have an account?'}
<Button variant="link" onclick={toggleMode} class="text-primary px-1 font-semibold">
{isLogin ? 'Sign up' : 'Sign in'}
</Button>
</div>
</div>
</div>
+195
View File
@@ -0,0 +1,195 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { toast } from 'svelte-sonner';
const services = [
{
category: 'Nail Services',
items: [
{
service: 'Classic Manicure',
duration: '45 minutes',
price: '£25.00',
description: 'Shape, cuticle care, polish'
},
{
service: 'Gel Manicure',
duration: '1 hour',
price: '£35.00',
description: 'Long-lasting gel polish application'
},
{
service: 'Classic Pedicure',
duration: '1 hour',
price: '£30.00',
description: 'Soak, exfoliate, shape, polish'
},
{
service: 'Gel Pedicure',
duration: '1 hour 15 minutes',
price: '£40.00',
description: 'Premium pedicure with gel polish'
},
{
service: 'Nail Art',
duration: '30 minutes',
price: '£15.00',
description: 'Custom designs and decorations'
},
{
service: 'Nail Removal',
duration: '20 minutes',
price: '£10.00',
description: 'Safe gel or acrylic removal'
}
]
},
{
category: 'Eyebrow Services',
items: [
{
service: 'Eyebrow Shaping',
duration: '30 minutes',
price: '£15.00',
description: 'Wax and tweeze to perfect shape'
},
{
service: 'Eyebrow Tint & Shape',
duration: '45 minutes',
price: '£25.00',
description: 'Shape and tint for definition'
}
]
},
{
category: 'Waxing Services',
items: [
{
service: 'Full Leg Wax',
duration: '1 hour',
price: '£35.00',
description: 'Smooth legs from ankle to hip'
},
{
service: 'Half Leg Wax',
duration: '30 minutes',
price: '£20.00',
description: 'Lower or upper leg waxing'
},
{
service: 'Bikini Wax',
duration: '30 minutes',
price: '£20.00',
description: 'Clean bikini line waxing'
},
{
service: 'Underarm Wax',
duration: '15 minutes',
price: '£12.00',
description: 'Quick and comfortable underarm waxing'
}
]
}
];
</script>
<div class="mx-auto max-w-4xl p-4 sm:p-6">
<div class="mb-6 text-center sm:mb-8">
<h1 class="mb-2 text-2xl font-bold sm:text-3xl">Our Prices</h1>
<p class="text-gray-600">Professional beauty treatments with transparent pricing</p>
</div>
<div class="space-y-8">
{#each services as category, categoryIndex}
<div>
<div class="mb-6">
<h2 class="text-xl font-semibold text-primary">{category.category}</h2>
</div>
<div class="space-y-4">
{#each category.items as service, index}
<!-- Service item layout -->
<div
class="rounded-lg border border-muted/40 bg-secondary/50 p-4 transition-colors hover:bg-secondary/80"
>
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<h4 class="font-semibold text-foreground">{service.service}</h4>
<p class="mt-1 text-sm text-muted-foreground">{service.description}</p>
<!-- Duration shown on mobile -->
<p class="mt-2 text-sm text-muted-foreground sm:hidden">
<span class="inline-flex items-center">
<svg
class="mr-1 h-3 w-3 text-primary/70"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V5z"
clip-rule="evenodd"
/>
</svg>
{service.duration}
</span>
</p>
</div>
<div class="ml-4 text-right">
<!-- Price always visible -->
<div class="text-lg font-semibold text-primary">{service.price}</div>
<!-- Duration shown on desktop -->
<div class="hidden text-sm text-muted-foreground sm:block">
{service.duration}
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Separator between categories -->
{#if categoryIndex < services.length - 1}
<Separator class="bg-muted/60" />
{/if}
{/each}
</div>
<!-- Important Information -->
<Card.Root
class="mt-6 border-amber-200/60 bg-gradient-to-r from-amber-50 to-amber-100/50 sm:mt-8"
>
<Card.Content class="pt-4 sm:pt-6">
<div class="space-y-2 text-sm text-amber-900">
<h4 class="font-semibold text-amber-800">Important Information:</h4>
<ul class="space-y-1 pl-4">
<li>
• Prices and durations are estimates and may vary based on individual requirements
</li>
<li>• Patch tests are required 24-48 hours before certain treatments</li>
<li>• 24 hours notice required for cancellations</li>
<li>• Payment is due at the time of service</li>
<li>• Deposit required for new and guest accounts</li>
</ul>
</div>
</Card.Content>
</Card.Root>
<!-- Call to Action -->
<div
class="mt-6 rounded-xl bg-gradient-to-br from-primary/5 to-secondary/30 p-6 text-center sm:mt-8 sm:p-8"
>
<h2 class="mb-4 text-lg font-semibold text-primary sm:text-xl">Ready to Book?</h2>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4">
<Button href="/book" class="px-6 py-3 sm:px-8">Book Appointment</Button>
<Button
href="/contact"
variant="outline"
class="border-primary/30 px-6 py-3 hover:bg-primary/10 sm:px-8"
>
Get in Touch
</Button>
</div>
</div>
</div>