feat: add email verification, profile pictures, deposits, and calendar

export
Backend:
- Add email verification code generation and verification endpoints
- Add profile picture upload with S3 storage and image processing
- Add deposit_required field to users with 48h advance booking
  requirement
- Add loyalty stamps that accumulate on completed bookings
- Auto-transition bookings: confirmed → in_progress → completed
- Add booking cancellation handler with no-show detection
- Add ICS calendar file download endpoint for bookings
- Sync bookings to CalDAV on confirmation
  Frontend:
- Add schedule page route
- Add avatar and image-cropper UI components
- Update shadcn-svelte components (button, dialog)
- Add "Add to Calendar" button in booking modal
  Database:
- Add verification_codes table
- Add profile_pic_url, loyalty_stamps, deposits_required to users
- Various schema updates
This commit is contained in:
2026-02-21 18:48:29 +00:00
parent 88d8469180
commit 970cc5554d
48 changed files with 1984 additions and 175 deletions
@@ -280,6 +280,15 @@
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
{#if selectedBooking}
<Button variant="outline" onclick={() => {
if (selectedBooking) {
window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank');
}
}}>
Add to Calendar
</Button>
{/if}
<Button onclick={() => (open = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
@@ -35,7 +35,14 @@
overrideDurationMinutes?: number;
};
let notes = $state(booking.notes || '');
let notes = $state('');
// Sync notes with booking.notes when booking changes
$effect(() => {
if (booking?.notes !== undefined) {
notes = booking.notes || '';
}
});
let serviceOverrides = $state<
Record<
string,
@@ -8,12 +8,13 @@
const links = [
{ href: '/', label: 'Home', showWhen: 'always', width: 'w-12' },
{ href: '/prices', label: 'Price List', showWhen: 'guest', width: 'w-20' },
{ href: '/book', label: 'Book your appointment', showWhen: 'auth', width: 'w-36' },
{ href: '/schedule', label: 'My Schedule', showWhen: 'auth', width: 'w-24' },
{ href: '/book', label: 'Book an appointment', showWhen: 'auth', width: 'w-36' },
{ href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' },
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' },
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' },
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' }
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' },
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' }
];
let mobileMenuOpen = $state(false);
@@ -131,7 +131,7 @@
interval = setInterval(() => {
calculateTimes();
}, 60000); // Update every minute
}, 15000); // Update every 15 seconds
return () => {
if (interval) clearInterval(interval);
@@ -193,18 +193,22 @@
{#if activeAppointment}
{#if isInProgress}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
<span class="relative mr-2 flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
</span>
In Progress • {timeRemaining} min remaining
{#if freeTimeAfter > 0}
{freeTimeAfter} min free
<div class="flex flex-col gap-2 items-end">
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
<span class="relative mr-2 flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
</span>
In Progress • {timeRemaining} min remaining
</Badge>
{#if freeTimeAfter > 0 && timeRemaining > 29}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
{freeTimeAfter} min free afterwards
</Badge>
{/if}
</Badge>
</div>
{:else}
<Badge class="bg-amber-100 px-3 py-1 text-sm text-amber-800">
Starts in {timeRemaining} min
@@ -259,10 +263,11 @@
class="h-20 w-20 rounded-full object-cover ring-4 ring-blue-200"
/>
{:else}
{@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'}
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-gray-200 text-2xl font-bold text-gray-600 ring-4 ring-blue-200"
>
{activeAppointment.user?.full_name?.charAt(0) || '?'}
{initials}
</div>
{/if}
<div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn('aspect-square size-full', className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
data-slot="avatar"
class={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
{...restProps}
/>
@@ -0,0 +1,13 @@
import Root from './avatar.svelte';
import Image from './avatar-image.svelte';
import Fallback from './avatar-fallback.svelte';
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback
};
@@ -1,82 +1,124 @@
<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";
import type { WithChildren, WithoutChildren } from 'bits-ui';
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",
base: "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden rounded-md text-sm font-medium whitespace-nowrap outline-hidden transition-all select-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs',
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",
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 text-white shadow-2xs',
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",
'bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 border shadow-2xs',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-2xs',
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",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
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",
},
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
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;
export type ButtonPropsWithoutHTML = WithChildren<{
ref?: HTMLElement | null;
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
'data-slot'?: string;
onClickPromise?: (
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => Promise<void>;
}>;
export type AnchorElementProps = ButtonPropsWithoutHTML &
WithoutChildren<Omit<HTMLAnchorAttributes, 'href' | 'type'>> & {
href: HTMLAnchorAttributes['href'];
type?: never;
disabled?: HTMLButtonAttributes['disabled'];
};
export type ButtonElementProps = ButtonPropsWithoutHTML &
WithoutChildren<Omit<HTMLButtonAttributes, 'type' | 'href'>> & {
type?: HTMLButtonAttributes['type'];
href?: never;
disabled?: HTMLButtonAttributes['disabled'];
};
export type ButtonProps = AnchorElementProps | ButtonElementProps;
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
variant = 'default',
size = 'default',
href = undefined,
type = "button",
disabled,
type = 'button',
loading = false,
disabled = false,
tabindex = 0,
onclick,
onClickPromise,
class: className,
'data-slot': dataSlot = 'button',
children,
...restProps
...rest
}: 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}
<!-- This approach to disabled links is inspired by bits-ui see: https://github.com/huntabyte/bits-ui/pull/1055 -->
<svelte:element
this={href ? 'a' : 'button'}
{...rest}
data-slot={dataSlot}
type={href ? undefined : type}
href={href && !disabled ? href : undefined}
disabled={href ? undefined : disabled || loading}
aria-disabled={href ? disabled : undefined}
role={href && disabled ? 'link' : undefined}
tabindex={href && disabled ? -1 : tabindex}
class={cn(buttonVariants({ variant, size }), className)}
bind:this={ref}
onclick={async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
e: any
) => {
onclick?.(e);
if (type === undefined) return;
if (onClickPromise) {
loading = true;
await onClickPromise(e);
loading = false;
}
}}
>
{#if type !== undefined && loading}
<div class="flex animate-spin place-items-center justify-center">
<LoaderCircleIcon class="size-4" />
</div>
<span class="sr-only">Loading</span>
{/if}
{@render children?.()}
</svelte:element>
@@ -2,8 +2,11 @@ import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML,
buttonVariants
} from './button.svelte';
export {
Root,
@@ -14,4 +17,7 @@ export {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML
};
@@ -1,5 +1,5 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
@@ -1,21 +1,21 @@
<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";
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,
hideClose = false,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
hideClose?: boolean;
} = $props();
</script>
@@ -25,15 +25,15 @@
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",
'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 top-[50%] left-[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}
{#if !hideClose}
<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"
class="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...restProps}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
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",
'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}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg font-semibold leading-none", className)}
class={cn('text-lg leading-none font-semibold', className)}
{...restProps}
/>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
+10 -10
View File
@@ -1,13 +1,13 @@
import { Dialog as DialogPrimitive } from "bits-ui";
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";
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;
@@ -33,5 +33,5 @@ export {
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
Close as DialogClose
};
@@ -0,0 +1,34 @@
<script lang="ts">
import { type ButtonElementProps, Button } from '$lib/components/ui/button';
import { useImageCropperCancel } from './image-cropper.svelte.js';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
let {
ref = $bindable(null),
variant = 'outline',
size = 'sm',
onclick,
...rest
}: ButtonElementProps = $props();
const cancelState = useImageCropperCancel();
</script>
<Button
{...rest}
bind:ref
{size}
{variant}
onclick={(
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => {
onclick?.(e);
cancelState.onclick();
}}
>
<Trash2Icon />
<span>Cancel</span>
</Button>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { ImageCropperControlsProps } from './types';
let {
ref = $bindable(null),
class: className,
children,
...rest
}: ImageCropperControlsProps = $props();
</script>
<div
{...rest}
bind:this={ref}
class={cn('flex w-full place-items-center justify-center gap-2', className)}
>
{@render children?.()}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { type ButtonElementProps, Button } from '$lib/components/ui/button';
import { useImageCropperCrop } from './image-cropper.svelte.js';
import CropIcon from '@lucide/svelte/icons/crop';
let {
ref = $bindable(null),
variant = 'default',
size = 'sm',
onclick,
...rest
}: ButtonElementProps = $props();
const cropState = useImageCropperCrop();
</script>
<Button
{...rest}
bind:ref
{size}
{variant}
onclick={(
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => {
onclick?.(e);
cropState.onclick();
}}
>
<CropIcon />
<span>Crop</span>
</Button>
@@ -0,0 +1,26 @@
<script lang="ts">
import Cropper from 'svelte-easy-crop';
import { useImageCropperCropper } from './image-cropper.svelte.js';
import type { ImageCropperCropperProps } from './types.js';
let {
cropShape = 'round',
aspect = 1,
showGrid = false,
...rest
}: ImageCropperCropperProps = $props();
const cropperState = useImageCropperCropper();
</script>
<!-- This needs to be relative https://github.com/ValentinH/svelte-easy-crop#basic-usage -->
<div class="relative h-full w-full">
<Cropper
{...rest}
{cropShape}
{aspect}
{showGrid}
image={cropperState.rootState.tempUrl}
oncropcomplete={cropperState.onCropComplete}
/>
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { cn } from '$lib/utils.js';
import { useImageCropperDialog } from './image-cropper.svelte.js';
import type { ImageCropperDialogProps } from './types';
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
const dialogState = useImageCropperDialog();
</script>
<Dialog.Root bind:open={dialogState.rootState.open}>
<Dialog.Content
{...rest}
hideClose
class={cn(
'min-h-96 max-w-full rounded-none border-x-0 sm:max-w-lg sm:rounded-lg sm:border-x',
className
)}
>
<div class="flex flex-col gap-4">
{@render children?.()}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,25 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar';
import type { ImageCropperPreviewProps } from './types';
import { useImageCropperPreview } from './image-cropper.svelte.js';
import UploadIcon from '@lucide/svelte/icons/upload';
import { cn } from '$lib/utils.js';
let { child, class: className }: ImageCropperPreviewProps = $props();
const previewState = useImageCropperPreview();
</script>
{#if child}
{@render child({ src: previewState.rootState.src })}
{:else}
<Avatar.Root
class={cn('ring-accent ring-offset-background size-20 ring-2 ring-offset-2', className)}
>
<Avatar.Image src={previewState.rootState.src} />
<Avatar.Fallback>
<UploadIcon class="size-4" />
<span class="sr-only">Upload image</span>
</Avatar.Fallback>
</Avatar.Root>
{/if}
@@ -0,0 +1,12 @@
<script lang="ts">
import { useImageCropperTrigger } from './image-cropper.svelte.js';
import type { ImageCropperUploadTriggerProps } from './types';
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props();
const triggerState = useImageCropperTrigger();
</script>
<label {...rest} bind:this={ref} for={triggerState.rootState.id} class="hover:cursor-pointer">
{@render children?.()}
</label>
@@ -0,0 +1,43 @@
<script lang="ts">
import { box } from 'svelte-toolbelt';
import { useImageCropperRoot } from './image-cropper.svelte.js';
import type { ImageCropperRootProps } from './types';
import { onDestroy } from 'svelte';
import { useId } from 'bits-ui';
let {
id = useId(),
src = $bindable(''),
onCropped = () => {},
onUnsupportedFile = () => {},
children,
...rest
}: ImageCropperRootProps = $props();
const rootState = useImageCropperRoot({
id: box.with(() => id),
src: box.with(
() => src,
(v) => (src = v)
),
onCropped: box.with(() => onCropped),
onUnsupportedFile: box.with(() => onUnsupportedFile)
});
onDestroy(() => rootState.dispose());
</script>
{@render children?.()}
<input
{...rest}
onchange={(e) => {
const file = e.currentTarget.files?.[0];
if (!file) return;
rootState.onUpload(file);
// reset so that we can reupload the same file
(e.target! as HTMLInputElement).value = '';
}}
type="file"
{id}
style="display: none;"
/>
@@ -0,0 +1,167 @@
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
import { Context } from 'runed';
import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
import { getCroppedImg } from './utils';
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats
export const VALID_IMAGE_TYPES = [
'image/apng',
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp'
];
export type ImageCropperRootProps = WritableBoxedValues<{
src: string;
}> &
ReadableBoxedValues<{
id: string;
onCropped: (url: string) => void;
onUnsupportedFile: (file: File) => void;
}>;
class ImageCropperRootState {
#createdUrls = $state<string[]>([]);
open = $state(false);
tempUrl = $state<string>();
pixelCrop = $state<CropArea>();
constructor(readonly opts: ImageCropperRootProps) {
this.onUpload = this.onUpload.bind(this);
this.onCancel = this.onCancel.bind(this);
this.onCrop = this.onCrop.bind(this);
this.dispose = this.dispose.bind(this);
}
onUpload(file: File) {
if (!VALID_IMAGE_TYPES.includes(file.type)) {
this.opts.onUnsupportedFile.current(file);
return;
}
this.tempUrl = URL.createObjectURL(file);
this.#createdUrls.push(this.tempUrl);
this.open = true;
}
onCancel() {
this.tempUrl = undefined;
this.open = false;
this.pixelCrop = undefined;
}
async onCrop() {
if (!this.pixelCrop || !this.tempUrl) return;
this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
this.open = false;
this.opts.onCropped.current(this.opts.src.current);
}
get src() {
return this.opts.src.current;
}
get id() {
return this.opts.id.current;
}
dispose() {
for (const url of this.#createdUrls) {
URL.revokeObjectURL(url);
}
}
}
export type ImageCropperTriggerProps = ReadableBoxedValues<{
id?: string;
}>;
class ImageCropperTriggerState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperPreviewState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperDialogState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperCropperState {
constructor(readonly rootState: ImageCropperRootState) {
this.onCropComplete = this.onCropComplete.bind(this);
}
onCropComplete(e: DispatchEvents['cropcomplete']) {
this.rootState.pixelCrop = e.pixels;
}
}
class ImageCropperCropState {
constructor(readonly rootState: ImageCropperRootState) {
this.onclick = this.onclick.bind(this);
}
onclick() {
this.rootState.onCrop();
}
}
class ImageCropperCancelState {
constructor(readonly rootState: ImageCropperRootState) {
this.onclick = this.onclick.bind(this);
}
onclick() {
this.rootState.onCancel();
}
}
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
return ImageCropperRootContext.set(new ImageCropperRootState(props));
};
export const useImageCropperTrigger = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperTriggerState(rootState);
};
export const useImageCropperPreview = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperPreviewState(rootState);
};
export const useImageCropperDialog = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperDialogState(rootState);
};
export const useImageCropperCropper = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCropperState(rootState);
};
export const useImageCropperCrop = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCropState(rootState);
};
export const useImageCropperCancel = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCancelState(rootState);
};
@@ -0,0 +1,13 @@
import Root from './image-cropper.svelte';
import UploadTrigger from './image-cropper-upload-trigger.svelte';
import Preview from './image-cropper-preview.svelte';
import Dialog from './image-cropper-dialog.svelte';
import Cropper from './image-cropper-cropper.svelte';
import Controls from './image-cropper-controls.svelte';
import Crop from './image-cropper-crop.svelte';
import Cancel from './image-cropper-cancel.svelte';
import { getFileFromUrl } from './utils';
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl };
export type * from './types';
@@ -0,0 +1,44 @@
import type {
AvatarRootProps,
DialogContentProps,
WithChildren,
WithoutChild,
WithoutChildren
} from 'bits-ui';
import type { Snippet } from 'svelte';
import type { CropperProps } from 'svelte-easy-crop';
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
export type ImageCropperRootPropsWithoutHTML = WithChildren<{
id?: string;
src?: string;
onCropped?: (url: string) => void;
onUnsupportedFile?: (file: File) => void;
}>;
export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
export type ImageCropperDialogProps = DialogContentProps;
export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>;
export type ImageCropperControlsWithoutHTML = WithChildren<{
ref?: HTMLDivElement | null;
}>;
export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLDivElement>>;
export type ImageCropperPreviewPropsWithoutHTML = {
child?: Snippet<[{ src: string }]>;
};
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML &
WithoutChild<AvatarRootProps>;
export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
ref?: HTMLLabelElement | null;
}>;
export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
@@ -0,0 +1,85 @@
import type { CropArea } from 'svelte-easy-crop';
export const getFileFromUrl = async (url: string, fileName = 'cropped.png'): Promise<File> => {
// Fetch the file data from the URL
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch resource: ${response.status} ${response.statusText}`);
}
// Convert the response into a Blob
const blob = await response.blob();
// Create and return a File. You can set a custom type if needed.
return new File([blob], fileName, { type: blob.type });
};
const createImage = (url: string): Promise<HTMLImageElement> => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
};
const getRadianAngle = (degreeValue: number) => {
return (degreeValue * Math.PI) / 180;
};
/** Gets the cropped image from the src using the cropped area
*
* @param imageSrc
* @param pixelCrop
* @param rotation
* @returns
*/
export const getCroppedImg = async (
imageSrc: string,
pixelCrop: CropArea,
rotation = 0
): Promise<string> => {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Error getting 2d rendering context');
}
const maxSize = Math.max(image.width, image.height);
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
// set each dimensions to double largest dimension to allow for a safe area for the
// image to rotate in without being clipped by canvas context
canvas.width = safeArea;
canvas.height = safeArea;
// translate canvas context to a central location on image to allow rotating around the center.
ctx.translate(safeArea / 2, safeArea / 2);
ctx.rotate(getRadianAngle(rotation));
ctx.translate(-safeArea / 2, -safeArea / 2);
// draw rotated image and store data.
ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
const data = ctx.getImageData(0, 0, safeArea, safeArea);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// paste generated rotate image with correct offsets for x,y crop values.
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
);
return new Promise((resolve) => {
canvas.toBlob((file) => {
resolve(URL.createObjectURL(file!));
}, 'image/png');
});
};
+3 -3
View File
@@ -182,9 +182,9 @@ class AuthStore {
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) {
// Refresh if token expires in less than 14 days
const fourteenDays = 14 * 24 * 60 * 60 * 1000;
if (decoded.exp * 1000 - Date.now() < fourteenDays) {
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
+4 -4
View File
@@ -1,13 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { type ClassValue, clsx } 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;
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 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 };
+152
View File
@@ -28,6 +28,8 @@
import { Separator } from '$lib/components/ui/separator';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Dialog from '$lib/components/ui/dialog';
import Cropper from 'svelte-easy-crop';
// =============== Auth & Page State ===============
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
@@ -80,6 +82,96 @@
let userData = $state<User | null>(null);
let loadingUser = $state(true);
let stamps = $state(0);
let uploadingPic = $state(false);
// Image cropper state
let cropDialogOpen = $state(false);
let cropImageUrl = $state('');
let cropArea = $state<{ x: number; y: number; width: number; height: number } | null>(null);
let crop = $state({ x: 0, y: 0 });
let zoom = $state(1);
let previewUrl = $state('');
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
cropImageUrl = URL.createObjectURL(file);
cropDialogOpen = true;
}
}
async function handleCropSave() {
if (!cropArea || !cropImageUrl) return;
const img = new Image();
img.src = cropImageUrl;
await new Promise(resolve => { img.onload = resolve; });
const canvas = document.createElement('canvas');
canvas.width = 350;
canvas.height = 350;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(
img,
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, 350, 350
);
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
previewUrl = url;
handleProfilePicUpload(blob).then(() => {
URL.revokeObjectURL(cropImageUrl);
cropImageUrl = '';
cropArea = null;
cropDialogOpen = false;
});
}, 'image/jpeg', 0.9);
}
function handleCropCancel() {
if (cropImageUrl) {
URL.revokeObjectURL(cropImageUrl);
}
cropImageUrl = '';
cropArea = null;
cropDialogOpen = false;
}
async function handleProfilePicUpload(blob: Blob) {
uploadingPic = true;
try {
const formData = new FormData();
formData.append('file', blob, 'profile.jpg');
const uploadResponse = await fetch('/api/user/profile-picture', {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
},
body: formData
});
if (uploadResponse.ok) {
const data = await uploadResponse.json();
if (userData) {
userData.profilePicUrl = data.url;
}
toast.success('Profile picture updated');
} else {
toast.error('Failed to upload profile picture');
}
} catch (err) {
console.error('Upload error:', err);
toast.error('Failed to upload profile picture');
} finally {
uploadingPic = false;
}
}
// =============== Phone Edit Mode ===============
let editingPhone = $state(false);
@@ -607,6 +699,66 @@
<Card.Description>Your personal details and account information</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if userData}
{@const initials = userData.firstName && userData.lastName ? userData.firstName.split(' ').map(n => n[0]).join('') + userData.lastName.split(' ').map(n => n[0]).join('') : ''}
{@const hasImage = !!userData.profilePicUrl || !!previewUrl}
{@const displayUrl = previewUrl || userData.profilePicUrl || ''}
<div class="flex flex-col items-center gap-4">
{#if hasImage || initials}
{#if hasImage}
<img src={displayUrl} alt="Profile" class="h-24 w-24 rounded-full object-cover ring-4 ring-blue-200" />
{:else}
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-blue-200">
{initials}
</div>
{/if}
{:else}
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-blue-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
{/if}
<Button variant="outline" onclick={() => document.getElementById('profile-pic-input')?.click()}>
Upload profile picture
</Button>
<input
id="profile-pic-input"
type="file"
accept="image/*"
class="hidden"
onchange={handleFileSelect}
/>
</div>
{/if}
<Dialog.Root bind:open={cropDialogOpen}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title>Crop Profile Picture</Dialog.Title>
</Dialog.Header>
<div class="relative h-64 w-full">
{#if cropImageUrl}
<Cropper
image={cropImageUrl}
aspect={1}
cropShape="round"
showGrid={false}
bind:crop
bind:zoom
oncropcomplete={(e) => {
cropArea = e.pixels;
}}
/>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={handleCropCancel}>Cancel</Button>
<Button onclick={handleCropSave}>Save</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{#if loadingUser}
{#each Array(6) as _, i (i)}
<Skeleton class="h-12 w-full" />
+55 -8
View File
@@ -1,15 +1,62 @@
<script lang="ts">
import ContactCard from '$lib/components/layout/ContactCard.svelte';
import { onMount } from 'svelte';
type ContactInfo = {
name: string;
role: string;
phone: string;
email: string;
profilePicUrl?: string;
};
let contact = $state<ContactInfo | null>(null);
let loading = $state(true);
onMount(async () => {
try {
const res = await fetch('/api/contact');
if (res.ok) {
contact = await res.json();
}
} catch (err) {
console.error('Failed to load contact info:', err);
} finally {
loading = false;
}
});
</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"
/>
{#if loading}
<div class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6">
<div class="mb-4 flex justify-center">
<div class="h-24 w-24 rounded-full bg-gray-200"></div>
</div>
<div class="mb-4 text-center">
<div class="mx-auto mb-2 h-6 w-40 rounded bg-gray-200"></div>
<div class="mx-auto h-4 w-32 rounded bg-gray-200"></div>
</div>
</div>
{:else if contact}
<ContactCard
name={contact.name}
role={contact.role}
phone={contact.phone}
email={contact.email}
instagram="crussell"
address="Business Centre, Office Street, Work"
profileImage={contact.profilePicUrl || 'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'}
/>
{:else}
<ContactCard
name="Chelsea Russell"
role="Owner / Beauty Specialist"
phone="+44 8008135"
email="chelsea@emailaddress.com"
instagram="crussell"
address="Business Centre, Office Street, Work"
/>
{/if}
</section>
+167
View File
@@ -0,0 +1,167 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
let bookings = $state<any[]>([]);
let loading = $state(false);
let selectedBookingId = $state<string | null>(null);
let showBookingModal = $state(false);
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated) {
pageState = 'unauthorized';
goto('/login', { replaceState: true });
return;
}
pageState = 'authorized';
fetchBookings();
});
async function fetchBookings() {
loading = true;
try {
const today = new Date().toISOString().split('T')[0];
const response = await fetch(`/api/bookings?start_date=${today}&per_page=50&page=1`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (!response.ok) {
toast.error('Failed to load bookings');
return;
}
const data = await response.json();
const now = new Date();
bookings = (data.bookings || [])
.filter((b: any) => {
const startTime = new Date(b.start_time);
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now;
})
.sort(
(a: any, b: any) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
);
} catch (err) {
console.error('Error fetching bookings:', err);
toast.error('Network error');
} finally {
loading = false;
}
}
function openBooking(id: string) {
selectedBookingId = id;
showBookingModal = true;
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-green-100 text-green-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-gray-100 text-gray-800'
};
</script>
{#if pageState === 'loading'}
<div class="mx-auto max-w-4xl p-6">
<div class="animate-pulse space-y-4">
<div class="h-8 w-48 rounded bg-gray-200"></div>
<div class="h-64 rounded bg-gray-200"></div>
</div>
</div>
{:else if pageState === 'unauthorized'}
<div class="mx-auto max-w-4xl p-6 text-center">
<p>Please log in to view your schedule.</p>
</div>
{:else}
<div class="mx-auto max-w-4xl p-6">
<h1 class="mb-6 text-2xl font-bold">My Schedule</h1>
{#if loading}
<div class="text-center">Loading...</div>
{:else if bookings.length === 0}
<Card.Root>
<Card.Header>
<Card.Title>No Upcoming Appointments</Card.Title>
<Card.Description>You don't have any upcoming appointments.</Card.Description>
</Card.Header>
<Card.Content>
<Button href="/book">Book an Appointment</Button>
</Card.Content>
</Card.Root>
{:else}
<div class="space-y-4">
{#each bookings as booking (booking.id)}
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div>
<Card.Title class="text-lg">
{new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</Card.Title>
<Card.Description>
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit'
})}
{#if booking.duration_minutes}
<span class="text-gray-500"> · {booking.duration_minutes} min</span>
{/if}
</Card.Description>
</div>
<span
class="rounded-full px-2 py-1 text-xs font-medium {statusColors[booking.status] ||
'bg-gray-100 text-gray-800'}"
>
{booking.status}
</span>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between">
<div>
{#if booking.services && booking.services.length > 0}
<p class="font-medium">
{booking.services.map((s: any) => s.service_name).join(', ')}
</p>
{/if}
{#if booking.total_amount}
<p class="text-sm text-gray-500">£{booking.total_amount.toFixed(2)}</p>
{/if}
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openBooking(booking.id)}>View Details</Button>
</div>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
{/if}
</div>
{/if}
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId || ''} />