Initial commit. Working login, example UI with prototype and demo, connections to DB and DAV, local and prod setups.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Vendored
+14
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user