chore: apply project-wide formatting and UI library updates

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-29 20:23:14 +01:00
co-authored by Sisyphus
parent ef650e013a
commit 6e901690fd
131 changed files with 2437 additions and 1571 deletions
+111 -111
View File
@@ -1,138 +1,138 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "tw-animate-css"; @import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695); --foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695); --card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695); --popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.28 0.04 260); --primary: oklch(0.28 0.04 260);
--primary-foreground: oklch(0.984 0.003 247.858); --primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.97 0.01 248); --secondary: oklch(0.97 0.01 248);
--secondary-foreground: oklch(0.208 0.042 265.755); --secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896); --muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417); --muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896); --accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755); --accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.64 0.21 25); --destructive: oklch(0.64 0.21 25);
--border: oklch(0.929 0.013 255.508); --border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508); --input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788); --ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858); --sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695); --sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755); --sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858); --sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896); --sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755); --sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508); --sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788); --sidebar-ring: oklch(0.704 0.04 256.788);
} }
.dark { .dark {
--background: oklch(0.129 0.042 264.695); --background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858); --foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755); --card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858); --card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755); --popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858); --popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508); --primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755); --primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031); --secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858); --secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031); --muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788); --muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031); --accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858); --accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364); --ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755); --sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858); --sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858); --sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031); --sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858); --sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364); --sidebar-ring: oklch(0.551 0.027 264.364);
} }
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
.calendar-container { .calendar-container {
z-index: 49 !important; z-index: 49 !important;
} }
[data-calendar] { [data-calendar] {
z-index: 49 !important; z-index: 49 !important;
} }
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
} }
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {
display: none; display: none;
} }
+1 -1
View File
@@ -10,5 +10,5 @@ declare global {
} }
} }
export { }; export {};
declare module 'swiper/svelte'; declare module 'swiper/svelte';
@@ -9,7 +9,11 @@
import * as Label from '$lib/components/ui/label'; import * as Label from '$lib/components/ui/label';
import DatePicker from '$lib/components/booking/DatePicker.svelte'; import DatePicker from '$lib/components/booking/DatePicker.svelte';
import type { Booking, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking'; import type { Booking, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import { extractBookedSlots, getLunchProtectionForSlots, timeToMinutes } from '$lib/lunchProtection'; import {
extractBookedSlots,
getLunchProtectionForSlots,
timeToMinutes
} from '$lib/lunchProtection';
import ClockIcon from '@lucide/svelte/icons/clock'; import ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left'; import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -94,32 +98,31 @@
); );
let canSubmit = $derived( let canSubmit = $derived(
submitting === false && ( submitting === false &&
(editMode === 'time' && !!newDate && newTime.length >= 4) || ((editMode === 'time' && !!newDate && newTime.length >= 4) ||
(editMode === 'services' && selectedServices.length > 0) || (editMode === 'services' && selectedServices.length > 0) ||
(editMode === 'both-time' && !!newDate && newTime.length >= 4) (editMode === 'both-time' && !!newDate && newTime.length >= 4))
)
); );
let notesChanged = $derived(notes.trim() !== originalNotes.trim()); let notesChanged = $derived(notes.trim() !== originalNotes.trim());
let modalTitle = $derived(() => { let modalTitle = $derived(() => {
switch (editMode) { switch (editMode) {
case 'select': return 'Edit Request'; case 'select':
case 'time': return 'Change Time'; return 'Edit Request';
case 'services': return 'Change Services'; case 'time':
case 'both-services': return 'Change Services'; return 'Change Time';
case 'both-time': return 'Choose Time'; case 'services':
return 'Change Services';
case 'both-services':
return 'Change Services';
case 'both-time':
return 'Choose Time';
} }
}); });
const lunchProtection = $derived(() => { const lunchProtection = $derived(() => {
if ( if (!newDate || !workingHours || !availableHours || slotDuration === 0) {
!newDate ||
!workingHours ||
!availableHours ||
slotDuration === 0
) {
return new Map(); return new Map();
} }
@@ -131,11 +134,7 @@
return new Map(); return new Map();
} }
const existingBookings = extractBookedSlots( const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
dayWH.startTime,
dayWH.endTime,
dayAH.slots
);
return getLunchProtectionForSlots( return getLunchProtectionForSlots(
dayWH.startTime, dayWH.startTime,
@@ -316,7 +315,11 @@
const dayAH = availableHours?.[dateStr]; const dayAH = availableHours?.[dateStr];
if (dayAH?.slots) { if (dayAH?.slots) {
const existingBookings = extractBookedSlots(dayHours.startTime, dayHours.endTime, dayAH.slots); const existingBookings = extractBookedSlots(
dayHours.startTime,
dayHours.endTime,
dayAH.slots
);
const lunchProtection = getLunchProtectionForSlots( const lunchProtection = getLunchProtectionForSlots(
dayHours.startTime, dayHours.startTime,
dayHours.endTime, dayHours.endTime,
@@ -390,7 +393,14 @@
// ─── Auto-select ──────────────────────────────────────── // ─── Auto-select ────────────────────────────────────────
$effect(() => { $effect(() => {
if (!workingHours || !availableHours || newDate || userNavigatedCalendar || editRequestAutoSelectDone) return; if (
!workingHours ||
!availableHours ||
newDate ||
userNavigatedCalendar ||
editRequestAutoSelectDone
)
return;
editRequestAutoSelectDone = true; editRequestAutoSelectDone = true;
const currentDate = new SvelteDate(); const currentDate = new SvelteDate();
@@ -419,11 +429,7 @@
if (!isDateUnavailable(calDate)) { if (!isDateUnavailable(calDate)) {
newDate = calDate; newDate = calDate;
if (!userNavigatedCalendar) { if (!userNavigatedCalendar) {
placeholderDate = new CalendarDate( placeholderDate = new CalendarDate(nextDate.getFullYear(), nextDate.getMonth() + 1, 1);
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1
);
} }
return; return;
} }
@@ -432,11 +438,7 @@
const tomorrow = new SvelteDate(); const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
newDate = new CalendarDate( newDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, tomorrow.getDate());
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
if (!userNavigatedCalendar) { if (!userNavigatedCalendar) {
placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1); placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
} }
@@ -716,7 +718,9 @@
</script> </script>
<Modal.Root bind:open> <Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"> <Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header> <Modal.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Modal.Title class="text-lg font-semibold">{modalTitle()}</Modal.Title> <Modal.Title class="text-lg font-semibold">{modalTitle()}</Modal.Title>
@@ -734,7 +738,9 @@
onclick={() => selectMode('time')} onclick={() => selectMode('time')}
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50" class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50"
> >
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"> <div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"
>
<ClockIcon class="size-5" /> <ClockIcon class="size-5" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -749,9 +755,13 @@
onclick={() => selectMode('services')} onclick={() => selectMode('services')}
disabled={hasOverrides} disabled={hasOverrides}
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50" class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50"
title={hasOverrides ? 'This booking has custom pricing. To change services, please contact the salon.' : ''} title={hasOverrides
? 'This booking has custom pricing. To change services, please contact the salon.'
: ''}
> >
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"> <div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"
>
<RefreshCwIcon class="size-5" /> <RefreshCwIcon class="size-5" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -766,11 +776,15 @@
onclick={() => selectMode('both-services')} onclick={() => selectMode('both-services')}
disabled={hasOverrides} disabled={hasOverrides}
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50" class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50"
title={hasOverrides ? 'This booking has custom pricing. To change services, please contact the salon.' : ''} title={hasOverrides
? 'This booking has custom pricing. To change services, please contact the salon.'
: ''}
> >
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"> <div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"
>
<ClockIcon class="size-5" /> <ClockIcon class="size-5" />
<RefreshCwIcon class="size-5 -ml-2" /> <RefreshCwIcon class="-ml-2 size-5" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="font-medium">Change Both</div> <div class="font-medium">Change Both</div>
@@ -781,11 +795,11 @@
{#if hasOverrides} {#if hasOverrides}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"> <div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
This booking has custom pricing. To change services, please contact the salon. You can still request a time change. This booking has custom pricing. To change services, please contact the salon. You can
still request a time change.
</div> </div>
{/if} {/if}
</div> </div>
{:else if editMode === 'time' || editMode === 'both-time'} {:else if editMode === 'time' || editMode === 'both-time'}
<!-- ─── Time Selection ────────────────────────── --> <!-- ─── Time Selection ────────────────────────── -->
{#if loadingHours && !workingHours} {#if loadingHours && !workingHours}
@@ -799,7 +813,7 @@
placeholder={placeholderDate} placeholder={placeholderDate}
minValue={minDate} minValue={minDate}
maxValue={maxCalendarDate} maxValue={maxCalendarDate}
isDateUnavailable={isDateUnavailable} {isDateUnavailable}
onchange={(d) => { onchange={(d) => {
newDate = d; newDate = d;
newTime = ''; newTime = '';
@@ -819,15 +833,15 @@
<p class="text-sm text-gray-500">Loading times...</p> <p class="text-sm text-gray-500">Loading times...</p>
</div> </div>
{:else} {:else}
<div class="no-scrollbar mt-2 flex min-h-[100px] max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"> <div
class="no-scrollbar mt-2 flex max-h-40 min-h-[100px] w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
>
<div class="grid justify-center gap-2 text-sm text-gray-600"> <div class="grid justify-center gap-2 text-sm text-gray-600">
{newDate {newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', {
.toDate(getLocalTimeZone()) weekday: 'long',
.toLocaleDateString('en-GB', { day: 'numeric',
weekday: 'long', month: 'short'
day: 'numeric', })}
month: 'short'
})}
</div> </div>
{#if workingHours && !workingHours[newDate.toString()]?.isOpen} {#if workingHours && !workingHours[newDate.toString()]?.isOpen}
<p class="text-center text-sm text-gray-500">We're closed on this day</p> <p class="text-center text-sm text-gray-500">We're closed on this day</p>
@@ -844,7 +858,9 @@
<Button <Button
variant="outline" variant="outline"
onclick={() => selectTime(slot.startTime)} onclick={() => selectTime(slot.startTime)}
class="w-full hover:bg-fuchsia-50 {newTime === slot.startTime ? 'bg-fuchsia-100 border-fuchsia-200' : ''}" class="w-full hover:bg-fuchsia-50 {newTime === slot.startTime
? 'border-fuchsia-200 bg-fuchsia-100'
: ''}"
> >
{formatTime(slot.startTime)} {formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span> <span class="text-gray-500">- {formatTime(slot.endTime)}</span>
@@ -878,7 +894,6 @@
rows={2} rows={2}
/> />
</div> </div>
{:else if editMode === 'services'} {:else if editMode === 'services'}
<!-- ─── Services Only (with time restriction) ── --> <!-- ─── Services Only (with time restriction) ── -->
{#if hasOverrides} {#if hasOverrides}
@@ -897,9 +912,7 @@
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Current Services Current Services
{#if selectedServices.length > 0} {#if selectedServices.length > 0}
<span class="ml-1 text-xs font-normal text-gray-400"> <span class="ml-1 text-xs font-normal text-gray-400"> (tap to remove) </span>
(tap to remove)
</span>
{/if} {/if}
</h4> </h4>
{#if selectedServices.length === 0} {#if selectedServices.length === 0}
@@ -912,16 +925,18 @@
onclick={() => { onclick={() => {
selectedServices = selectedServices.filter((s) => s.id !== service.id); selectedServices = selectedServices.filter((s) => s.id !== service.id);
}} }}
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50 bg-fuchsia-100" class="cursor-pointer rounded-lg border border-input bg-background bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50"
> >
<div class="flex flex-col justify-between h-full min-h-[6rem]"> <div class="flex h-full min-h-[6rem] flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p> <p class="text-sm text-gray-600">{service.description || ''}</p>
</div> </div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500"> <div class="mt-2 flex items-center justify-between text-sm text-gray-500">
<span>{service.duration_minutes} mins</span> <span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span> <span class="font-semibold text-foreground"
>£{service.price.toFixed(2)}</span
>
</div> </div>
</div> </div>
</button> </button>
@@ -942,24 +957,26 @@
</h4> </h4>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2"> <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each fittingServices as service (service.id)} {#each fittingServices as service (service.id)}
<button <button
type="button" type="button"
onclick={() => { onclick={() => {
selectedServices = [...selectedServices, service]; selectedServices = [...selectedServices, service];
}} }}
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50" class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
> >
<div class="flex flex-col justify-between h-full min-h-[6rem]"> <div class="flex h-full min-h-[6rem] flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p> <p class="text-sm text-gray-600">{service.description || ''}</p>
</div>
<div class="mt-2 flex items-center justify-between text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground"
>£{service.price.toFixed(2)}</span
>
</div>
</div> </div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500"> </button>
<span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
</div>
</div>
</button>
{/each} {/each}
</div> </div>
</div> </div>
@@ -969,15 +986,15 @@
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"> <div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
>
No remaining time available. Remove a service to free up time for additions. No remaining time available. Remove a service to free up time for additions.
</div> </div>
{/if} {/if}
{#if selectedServices.length === 0} {#if selectedServices.length === 0}
<p class="text-xs text-amber-600"> <p class="text-xs text-amber-600">Select at least one service to continue.</p>
Select at least one service to continue.
</p>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -991,7 +1008,8 @@
</div> </div>
{#if originalNotes} {#if originalNotes}
<div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500"> <div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500">
<span class="font-medium">Original:</span> {originalNotes} <span class="font-medium">Original:</span>
{originalNotes}
</div> </div>
{/if} {/if}
<Textarea.Root <Textarea.Root
@@ -1001,7 +1019,6 @@
rows={2} rows={2}
/> />
</div> </div>
{:else if editMode === 'both-services'} {:else if editMode === 'both-services'}
<!-- ─── Both Step 1: Service Selection (no time restriction) ── --> <!-- ─── Both Step 1: Service Selection (no time restriction) ── -->
{#if hasOverrides} {#if hasOverrides}
@@ -1022,9 +1039,7 @@
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Selected Services Selected Services
{#if selectedServices.length > 0} {#if selectedServices.length > 0}
<span class="ml-1 text-xs font-normal text-gray-400"> <span class="ml-1 text-xs font-normal text-gray-400"> (tap to remove) </span>
(tap to remove)
</span>
{/if} {/if}
</h4> </h4>
{#if selectedServices.length === 0} {#if selectedServices.length === 0}
@@ -1037,16 +1052,18 @@
onclick={() => { onclick={() => {
selectedServices = selectedServices.filter((s) => s.id !== service.id); selectedServices = selectedServices.filter((s) => s.id !== service.id);
}} }}
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50 bg-fuchsia-100" class="cursor-pointer rounded-lg border border-input bg-background bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50"
> >
<div class="flex flex-col justify-between h-full min-h-[6rem]"> <div class="flex h-full min-h-[6rem] flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p> <p class="text-sm text-gray-600">{service.description || ''}</p>
</div> </div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500"> <div class="mt-2 flex items-center justify-between text-sm text-gray-500">
<span>{service.duration_minutes} mins</span> <span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span> <span class="font-semibold text-foreground"
>£{service.price.toFixed(2)}</span
>
</div> </div>
</div> </div>
</button> </button>
@@ -1069,14 +1086,16 @@
}} }}
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50" class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
> >
<div class="flex flex-col justify-between h-full min-h-[6rem]"> <div class="flex h-full min-h-[6rem] flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p> <p class="text-sm text-gray-600">{service.description || ''}</p>
</div> </div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500"> <div class="mt-2 flex items-center justify-between text-sm text-gray-500">
<span>{service.duration_minutes} mins</span> <span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span> <span class="font-semibold text-foreground"
>£{service.price.toFixed(2)}</span
>
</div> </div>
</div> </div>
</button> </button>
@@ -1086,9 +1105,7 @@
{/if} {/if}
{#if selectedServices.length === 0} {#if selectedServices.length === 0}
<p class="text-xs text-amber-600"> <p class="text-xs text-amber-600">Select at least one service to continue.</p>
Select at least one service to continue.
</p>
{/if} {/if}
</div> </div>
@@ -1101,7 +1118,8 @@
</div> </div>
{#if originalNotes} {#if originalNotes}
<div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500"> <div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500">
<span class="font-medium">Original:</span> {originalNotes} <span class="font-medium">Original:</span>
{originalNotes}
</div> </div>
{/if} {/if}
<Textarea.Root <Textarea.Root
@@ -1122,13 +1140,8 @@
<Button variant="ghost" size="sm" class="flex-1" onclick={() => (open = false)}> <Button variant="ghost" size="sm" class="flex-1" onclick={() => (open = false)}>
Cancel Cancel
</Button> </Button>
{:else if editMode === 'time' || editMode === 'services'} {:else if editMode === 'time' || editMode === 'services'}
<Button <Button variant="outline" size="sm" onclick={goBack}>
variant="outline"
size="sm"
onclick={goBack}
>
<ArrowLeftIcon class="size-4" /> <ArrowLeftIcon class="size-4" />
Back Back
</Button> </Button>
@@ -1142,13 +1155,8 @@
> >
{submitting ? 'Submitting...' : 'Submit Request'} {submitting ? 'Submitting...' : 'Submit Request'}
</Button> </Button>
{:else if editMode === 'both-services'} {:else if editMode === 'both-services'}
<Button <Button variant="outline" size="sm" onclick={goBack}>
variant="outline"
size="sm"
onclick={goBack}
>
<ArrowLeftIcon class="size-4" /> <ArrowLeftIcon class="size-4" />
Back Back
</Button> </Button>
@@ -1165,13 +1173,8 @@
Next: Choose Time Next: Choose Time
<ArrowRightIcon class="size-4" /> <ArrowRightIcon class="size-4" />
</Button> </Button>
{:else if editMode === 'both-time'} {:else if editMode === 'both-time'}
<Button <Button variant="outline" size="sm" onclick={goBack}>
variant="outline"
size="sm"
onclick={goBack}
>
<ArrowLeftIcon class="size-4" /> <ArrowLeftIcon class="size-4" />
Back Back
</Button> </Button>
@@ -45,9 +45,7 @@
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status) selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
); );
let canEditBooking = $derived( let canEditBooking = $derived(isCancellable && !hasPayments);
isCancellable && !hasPayments
);
let totalPaid = $derived( let totalPaid = $derived(
selectedBooking?.payments selectedBooking?.payments
@@ -249,7 +247,9 @@
</script> </script>
<Modal.Root bind:open> <Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"> <Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header> <Modal.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -500,7 +500,6 @@
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -516,14 +515,16 @@
Cancel Booking Cancel Booking
</Button> </Button>
{#if canEditBooking} {#if canEditBooking}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="flex-1" class="flex-1"
onclick={() => { showEditModal = true; }} onclick={() => {
> showEditModal = true;
Edit Request }}
</Button> >
Edit Request
</Button>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -66,16 +66,20 @@
function getBookingDateTime(): string { function getBookingDateTime(): string {
if (!booking?.start_time) return ''; if (!booking?.start_time) return '';
const d = new SvelteDate(booking.start_time); const d = new SvelteDate(booking.start_time);
return d.toLocaleDateString('en-GB', { return (
weekday: 'long', d.toLocaleDateString('en-GB', {
day: 'numeric', weekday: 'long',
month: 'long', day: 'numeric',
year: 'numeric' month: 'long',
}) + ' at ' + d.toLocaleTimeString('en-GB', { year: 'numeric'
hour: 'numeric', }) +
minute: '2-digit', ' at ' +
hour12: true d.toLocaleTimeString('en-GB', {
}); hour: 'numeric',
minute: '2-digit',
hour12: true
})
);
} }
function getTotalCost(): number { function getTotalCost(): number {
@@ -447,36 +451,36 @@
</div> </div>
{/if} {/if}
<!-- Customer Contact Info --> <!-- Customer Contact Info -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Contact Customer Contact
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.full_name || '—'}</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div> <div>
<div class="text-xs text-gray-500">Phone</div> <div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.phone || '—'}</div> <div class="font-medium">{booking.user?.full_name || '—'}</div>
</div> </div>
<div> <div class="grid gap-3 md:grid-cols-2">
<div class="text-xs text-gray-500">Email</div> <div>
<div class="font-medium break-all">{booking.user?.email || '—'}</div> <div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{booking.user?.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Booking Date & Time --> <!-- Booking Date & Time -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Booking Date & Time Booking Date & Time
</h3> </h3>
<div class="text-lg font-medium">{getBookingDateTime()}</div> <div class="text-lg font-medium">{getBookingDateTime()}</div>
</div> </div>
<!-- Booking Notes --> <!-- Booking Notes -->
<div> <div>
@@ -74,7 +74,11 @@
// Step 4: Date & Time // Step 4: Date & Time
let placeholder = $state<CalendarDate>( let placeholder = $state<CalendarDate>(
new CalendarDate(new SvelteDate().getFullYear(), new SvelteDate().getMonth() + 1, new SvelteDate().getDate()) new CalendarDate(
new SvelteDate().getFullYear(),
new SvelteDate().getMonth() + 1,
new SvelteDate().getDate()
)
); );
let selectedDate = $state<CalendarDate | undefined>(undefined); let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null); let selectedTime = $state<string | null>(null);
@@ -129,7 +133,7 @@
// =============== Lunch Protection =============== // =============== Lunch Protection ===============
const lunchProtection = $derived( const lunchProtection = $derived(
(selectedDate && selectedServices.length > 0) selectedDate && selectedServices.length > 0
? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true) ? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true)
: new Map() : new Map()
); );
@@ -197,7 +201,16 @@
// Auto-select first available date once data loads (timing-safe, data-driven) // Auto-select first available date once data loads (timing-safe, data-driven)
let bookingCreateAutoSelectDone = $state(false); let bookingCreateAutoSelectDone = $state(false);
$effect(() => { $effect(() => {
if (open && currentStep === 4 && workingHours && availableHours && !selectedDate && selectedServices.length > 0 && !userNavigatedCalendar && !bookingCreateAutoSelectDone) { if (
open &&
currentStep === 4 &&
workingHours &&
availableHours &&
!selectedDate &&
selectedServices.length > 0 &&
!userNavigatedCalendar &&
!bookingCreateAutoSelectDone
) {
bookingCreateAutoSelectDone = true; bookingCreateAutoSelectDone = true;
const now = new SvelteDate(); const now = new SvelteDate();
const maxDateJs = new SvelteDate( const maxDateJs = new SvelteDate(
@@ -213,7 +226,11 @@
const checkDate = new SvelteDate(now); const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i); checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toISOString().split('T')[0]; const dateStr = checkDate.toISOString().split('T')[0];
const calDate = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, checkDate.getDate()); const calDate = new CalendarDate(
checkDate.getFullYear(),
checkDate.getMonth() + 1,
checkDate.getDate()
);
if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) { if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
selectedDate = calDate; selectedDate = calDate;
if (!userNavigatedCalendar) { if (!userNavigatedCalendar) {
@@ -342,7 +359,9 @@
const whMap: Record<string, any> = {}; const whMap: Record<string, any> = {};
const ahMap: Record<string, any> = {}; const ahMap: Record<string, any> = {};
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })); whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
);
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots })); ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
if (gen !== hoursRangeGeneration) return; // Stale response, discard if (gen !== hoursRangeGeneration) return; // Stale response, discard
@@ -435,7 +454,7 @@
} finally { } finally {
loadingWorkingHours = false; loadingWorkingHours = false;
loadingAvailableHours = false; loadingAvailableHours = false;
loadingMonthKeys = new Set([...loadingMonthKeys].filter(k => k !== monthKey)); loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey));
} }
} }
@@ -575,7 +594,13 @@
const groupedTimeSlots = $derived( const groupedTimeSlots = $derived(
currentStep === 4 && selectedServices.length > 0 && selectedDate currentStep === 4 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(selectedDate, workingHours, availableHours, getTotalDuration(), lunchProtection) ? generateGroupedTimeSlots(
selectedDate,
workingHours,
availableHours,
getTotalDuration(),
lunchProtection
)
: [] : []
); );
@@ -599,8 +624,20 @@
if (duration <= 0) return true; if (duration <= 0) return true;
// Build lunch protection specifically for the date being checked // Build lunch protection specifically for the date being checked
const dayProtection = buildLunchProtection(date as CalendarDate, workingHours, availableHours, duration, true); const dayProtection = buildLunchProtection(
const slots = generateAvailableTimeSlots(date as CalendarDate, workingHours, availableHours, duration, dayProtection); date as CalendarDate,
workingHours,
availableHours,
duration,
true
);
const slots = generateAvailableTimeSlots(
date as CalendarDate,
workingHours,
availableHours,
duration,
dayProtection
);
if (slots.length === 0) return true; if (slots.length === 0) return true;
return false; return false;
@@ -620,32 +657,32 @@
try { try {
let finalUserId = selectedUserId; let finalUserId = selectedUserId;
if (userType === 'guest') { if (userType === 'guest') {
const phone = guestPhone.trim() || '+447700900000'; const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', { const createRes = await fetch('/api/users/guest', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}` Authorization: `Bearer ${authStore.currentToken}`
}, },
body: JSON.stringify({ body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Guest', firstName: guestName.trim().split(' ')[0] || 'Guest',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Customer', lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Customer',
phone: phone, phone: phone,
email: `callin-${Date.now()}@guest.invalid` email: `callin-${Date.now()}@guest.invalid`
}) })
}); });
if (!createRes.ok) { if (!createRes.ok) {
toast.error('Failed to create guest user'); toast.error('Failed to create guest user');
submitting = false; submitting = false;
return; return;
}
const guestUser = await createRes.json();
finalUserId = guestUser.id;
} }
const guestUser = await createRes.json();
finalUserId = guestUser.id;
}
if (!finalUserId) throw new Error('User ID required'); if (!finalUserId) throw new Error('User ID required');
if (!selectedDate || !selectedTime) throw new Error('Date and time required'); if (!selectedDate || !selectedTime) throw new Error('Date and time required');
@@ -734,7 +771,9 @@
</script> </script>
<Modal.Root bind:open> <Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl"> <Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl"
>
<Modal.Header> <Modal.Header>
<Modal.Title>Create Admin Booking</Modal.Title> <Modal.Title>Create Admin Booking</Modal.Title>
<Modal.Description> <Modal.Description>
@@ -1096,17 +1135,30 @@
<Card.Content class="space-y-4 p-0"> <Card.Content class="space-y-4 p-0">
<!-- Reservation Countdown Banner --> <!-- Reservation Countdown Banner -->
{#if reservationId && reservationExpiresAt} {#if reservationId && reservationExpiresAt}
<div class="mx-6 mt-4 rounded-lg bg-green-50 border border-green-200 p-3"> <div class="mx-6 mt-4 rounded-lg border border-green-200 bg-green-50 p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> class="h-5 w-5 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg> </svg>
<span class="text-sm font-medium text-green-800"> <span class="text-sm font-medium text-green-800">
Slot reserved until {reservationExpiresAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} Slot reserved until {reservationExpiresAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</span> </span>
</div> </div>
<span class="text-sm font-mono font-semibold text-green-700"> <span class="font-mono text-sm font-semibold text-green-700">
({reservationCountdown} remaining) ({reservationCountdown} remaining)
</span> </span>
</div> </div>
@@ -1123,11 +1175,11 @@
selectedDate = newDate; selectedDate = newDate;
selectedTime = null; selectedTime = null;
}} }}
onPlaceholderChange={(newPlaceholder) => { onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder; placeholder = newPlaceholder;
userNavigatedCalendar = true; userNavigatedCalendar = true;
fetchHoursForMonth(newPlaceholder); fetchHoursForMonth(newPlaceholder);
}} }}
/> />
</div> </div>
@@ -1139,14 +1191,18 @@
<div class="border-t"> <div class="border-t">
<div class="max-h-64 overflow-y-auto p-6"> <div class="max-h-64 overflow-y-auto p-6">
{#if formattedSelectedDate} {#if formattedSelectedDate}
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">{formattedSelectedDate}</div> <div class="mb-3 grid justify-center gap-2 text-sm font-medium">
{formattedSelectedDate}
</div>
{/if} {/if}
<TimeSlotList <TimeSlotList
slots={groupedTimeSlots} slots={groupedTimeSlots}
{selectedTime} {selectedTime}
duration={getTotalDuration()} duration={getTotalDuration()}
protection={lunchProtection} protection={lunchProtection}
onSelect={(time) => { selectedTime = time; }} onSelect={(time) => {
selectedTime = time;
}}
/> />
</div> </div>
</div> </div>
@@ -1177,7 +1233,11 @@
onclick={submitBooking} onclick={submitBooking}
class="bg-primary text-primary-foreground" class="bg-primary text-primary-foreground"
> >
{isReserving ? 'Reserving Slot...' : submitting ? 'Creating Booking...' : 'Create Booking'} {isReserving
? 'Reserving Slot...'
: submitting
? 'Creating Booking...'
: 'Create Booking'}
</Button> </Button>
</Card.Footer> </Card.Footer>
</Card.Root> </Card.Root>
@@ -25,8 +25,10 @@
// customised at booking time (discounts, extended sessions). Showing base values // customised at booking time (discounts, extended sessions). Showing base values
// misleads admins about what was actually booked. // misleads admins about what was actually booked.
let totalDuration = $derived( let totalDuration = $derived(
selectedBooking?.services?.reduce((sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0), 0) || selectedBooking?.services?.reduce(
(sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0),
0 0
) || 0
); );
async function fetchBookingDetails() { async function fetchBookingDetails() {
@@ -135,7 +137,9 @@
</script> </script>
<Modal.Root bind:open> <Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"> <Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header> <Modal.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -145,7 +149,9 @@
{/if} {/if}
</div> </div>
{#if selectedBooking} {#if selectedBooking}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)} {@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(
selectedBooking.status
)}
{#if selectedBooking.status === 'pending'} {#if selectedBooking.status === 'pending'}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -175,14 +181,14 @@
Pending Pending
</span> </span>
<!-- Deposit Badge for pending --> <!-- Deposit Badge for pending -->
{#if selectedBooking.deposit_required} {#if selectedBooking.deposit_required}
<span <span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800" class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
> >
Will Require Deposit Will Require Deposit
</span> </span>
{/if} {/if}
</div> </div>
{:else if isConfirmedOrLater && selectedBooking.deposit_required} {:else if isConfirmedOrLater && selectedBooking.deposit_required}
<!-- Deposit Badge for confirmed+ --> <!-- Deposit Badge for confirmed+ -->
@@ -197,14 +203,12 @@
> >
{selectedBooking.status.replace('_', ' ')} {selectedBooking.status.replace('_', ' ')}
</span> </span>
<span <span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid {selectedBooking.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
? 'bg-green-100 text-green-800' >
: 'bg-orange-100 text-orange-800'}" {selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
> </span>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -270,8 +274,14 @@
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div> <div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
{/if} {/if}
<div class="mt-2 flex items-center justify-between text-sm"> <div class="mt-2 flex items-center justify-between text-sm">
<span class="text-gray-600">{formatDuration(service.override_duration_minutes ?? service.duration_minutes ?? 0)}</span> <span class="text-gray-600"
<span class="font-semibold">£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span> >{formatDuration(
service.override_duration_minutes ?? service.duration_minutes ?? 0
)}</span
>
<span class="font-semibold"
>£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span
>
</div> </div>
</div> </div>
{/each} {/each}
@@ -296,7 +306,9 @@
<div> <div>
<div class="text-lg font-semibold">{selectedBooking.user?.full_name || '—'}</div> <div class="text-lg font-semibold">{selectedBooking.user?.full_name || '—'}</div>
{#if selectedBooking.user?.date_of_birth} {#if selectedBooking.user?.date_of_birth}
<div class="text-sm text-gray-500">{calculateAge(selectedBooking.user.date_of_birth)} years old</div> <div class="text-sm text-gray-500">
{calculateAge(selectedBooking.user.date_of_birth)} years old
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -308,7 +320,8 @@
<a <a
href="mailto:{selectedBooking.user.email}" href="mailto:{selectedBooking.user.email}"
class="font-medium break-words text-blue-600 hover:underline" class="font-medium break-words text-blue-600 hover:underline"
>{selectedBooking.user.email}</a> >{selectedBooking.user.email}</a
>
{:else} {:else}
<div class="font-medium"></div> <div class="font-medium"></div>
{/if} {/if}
@@ -318,8 +331,8 @@
{#if selectedBooking.user?.phone} {#if selectedBooking.user?.phone}
<a <a
href="tel:{selectedBooking.user.phone}" href="tel:{selectedBooking.user.phone}"
class="font-medium text-blue-600 hover:underline" class="font-medium text-blue-600 hover:underline">{selectedBooking.user.phone}</a
>{selectedBooking.user.phone}</a> >
{:else} {:else}
<div class="font-medium"></div> <div class="font-medium"></div>
{/if} {/if}
@@ -328,19 +341,26 @@
<div> <div>
<div class="text-xs text-gray-500">Referral Code</div> <div class="text-xs text-gray-500">Referral Code</div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<code class="rounded bg-gray-200 px-1.5 py-0.5 text-sm font-mono font-medium"> <code class="rounded bg-gray-200 px-1.5 py-0.5 font-mono text-sm font-medium">
{selectedBooking.user.referral_code} {selectedBooking.user.referral_code}
</code> </code>
<button <button
type="button" type="button"
title="Copy referral code" title="Copy referral code"
class="inline-flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition-colors" class="inline-flex items-center justify-center rounded p-1 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700"
onclick={() => { onclick={() => {
navigator.clipboard.writeText(selectedBooking.user!.referral_code!); navigator.clipboard.writeText(selectedBooking.user!.referral_code!);
toast.success('Referral code copied'); toast.success('Referral code copied');
}} }}
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /> <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg> </svg>
@@ -368,30 +388,30 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Financial Summary Financial Summary
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required} {#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<!-- Deposit Info --> <!-- Deposit Info -->
<div class="flex items-center justify-between border-b border-gray-200 pb-2"> <div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span> <span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right"> <div class="text-right">
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div> <div class="font-semibold">
<div class="text-xs"> £{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
<span </div>
class="{selectedBooking.deposit_paid <div class="text-xs">
? 'text-green-600' <span
: 'text-orange-600'}" class={selectedBooking.deposit_paid ? 'text-green-600' : 'text-orange-600'}
> >
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {formatDateTime(selectedBooking.deposit_deadline)}
</span> </span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline} {/if}
<span class="text-gray-500">
• Due: {formatDateTime(selectedBooking.deposit_deadline)}
</span>
{/if}
</div>
</div> </div>
</div> </div>
{/if} </div>
{/if}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span> <span class="text-sm text-gray-600">Total Amount</span>
@@ -494,10 +514,7 @@
<Modal.Footer class="flex items-center justify-end gap-2"> <Modal.Footer class="flex items-center justify-end gap-2">
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit'].includes(selectedBooking.status)} {#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit'].includes(selectedBooking.status)}
<Button <Button variant="outline" onclick={() => (showRescheduleModal = true)}>
variant="outline"
onclick={() => (showRescheduleModal = true)}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4" class="mr-2 h-4 w-4"
@@ -506,7 +523,9 @@
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
> >
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" /> <path
d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"
/>
</svg> </svg>
Reschedule Reschedule
</Button> </Button>
@@ -246,35 +246,33 @@
<div class="font-medium"> <div class="font-medium">
{formatBookingDateTime(b.start_time)} {formatBookingDateTime(b.start_time)}
</div> </div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500"> <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}> <span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span> <span class={getStatusDotClasses(b.status)}></span>
{b.status} {b.status}
</span> </span>
{#if b.deposit_required} {#if b.deposit_required}
{#if b.status === 'pending'} {#if b.status === 'pending'}
<span <span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800" class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
> >
Will Require Deposit Will Require Deposit
</span> </span>
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)} {:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{b.deposit_paid {b.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
? 'bg-green-100 text-green-800' >
: 'bg-orange-100 text-orange-800'}" {b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
> </span>
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
{/if} {/if}
<span>{b.user?.full_name || 'Unknown User'}</span> {/if}
<span> <span>{b.user?.full_name || 'Unknown User'}</span>
- {formatServices(b.services)} <span>
</span> - {formatServices(b.services)}
</div> </span>
</div> </div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button> <Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div> </div>
{/each} {/each}
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format'; import { formatDuration } from '$lib/utils/format';
import * as Modal from '$lib/components/ui/dialog'; import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -172,7 +172,10 @@ import { formatDuration } from '$lib/utils/format';
overrideOriginalPrice = service.price ?? 0; overrideOriginalPrice = service.price ?? 0;
overrideOriginalDuration = service.duration_minutes ?? 0; overrideOriginalDuration = service.duration_minutes ?? 0;
overridePrice = service.override_price !== undefined ? service.override_price.toFixed(2) : ''; overridePrice = service.override_price !== undefined ? service.override_price.toFixed(2) : '';
overrideDuration = service.override_duration_minutes !== undefined ? service.override_duration_minutes.toString() : ''; overrideDuration =
service.override_duration_minutes !== undefined
? service.override_duration_minutes.toString()
: '';
showOverrideModal = true; showOverrideModal = true;
} }
@@ -299,8 +302,7 @@ import { formatDuration } from '$lib/utils/format';
service_ids: services.map((s) => s.service_id), service_ids: services.map((s) => s.service_id),
service_overrides: services service_overrides: services
.filter( .filter(
(s) => (s) => s.override_price !== undefined || s.override_duration_minutes !== undefined
s.override_price !== undefined || s.override_duration_minutes !== undefined
) )
.map((s) => ({ .map((s) => ({
service_id: s.service_id, service_id: s.service_id,
@@ -391,7 +393,9 @@ import { formatDuration } from '$lib/utils/format';
</script> </script>
<Modal.Root bind:open> <Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"> <Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header> <Modal.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -409,9 +413,7 @@ import { formatDuration } from '$lib/utils/format';
<div class="space-y-6 px-4 pb-4"> <div class="space-y-6 px-4 pb-4">
<!-- Customer Info --> <!-- Customer Info -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">Customer</h3>
Customer
</h3>
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
<div> <div>
<div class="text-xs text-gray-500">Name</div> <div class="text-xs text-gray-500">Name</div>
@@ -419,7 +421,9 @@ import { formatDuration } from '$lib/utils/format';
</div> </div>
<div> <div>
<div class="text-xs text-gray-500">Email</div> <div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-words" title={booking.user?.email}>{booking.user?.email || '—'}</div> <div class="font-medium break-words" title={booking.user?.email}>
{booking.user?.email || '—'}
</div>
</div> </div>
<div> <div>
<div class="text-xs text-gray-500">Phone</div> <div class="text-xs text-gray-500">Phone</div>
@@ -465,17 +469,23 @@ import { formatDuration } from '$lib/utils/format';
</div> </div>
{#if services.length === 0} {#if services.length === 0}
<div class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500"> <div
class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500"
>
No services added yet. Click "Add Service" to begin. No services added yet. Click "Add Service" to begin.
</div> </div>
{:else} {:else}
<div class="space-y-2"> <div class="space-y-2">
{#each services as service, index (index)} {#each services as service, index (index)}
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3"> <div
class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3"
>
<div class="flex-1"> <div class="flex-1">
<div class="font-medium">{service.service_name}</div> <div class="font-medium">{service.service_name}</div>
<div class="mt-1 text-sm text-gray-600"> <div class="mt-1 text-sm text-gray-600">
{formatDuration(service.override_duration_minutes ?? service.duration_minutes ?? 0)} • £{(service.override_price ?? service.price ?? 0).toFixed(2)} {formatDuration(
service.override_duration_minutes ?? service.duration_minutes ?? 0
)} • £{(service.override_price ?? service.price ?? 0).toFixed(2)}
</div> </div>
{#if service.override_price !== undefined || service.override_duration_minutes !== undefined} {#if service.override_price !== undefined || service.override_duration_minutes !== undefined}
<div class="text-xs text-amber-600">Modified</div> <div class="text-xs text-amber-600">Modified</div>
@@ -523,7 +533,9 @@ import { formatDuration } from '$lib/utils/format';
{/each} {/each}
</div> </div>
<div class="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 text-sm"> <div
class="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 text-sm"
>
<span class="text-gray-600">Total: {formatDuration(totalDuration)}</span> <span class="text-gray-600">Total: {formatDuration(totalDuration)}</span>
<span class="font-semibold">£{totalPrice.toFixed(2)}</span> <span class="font-semibold">£{totalPrice.toFixed(2)}</span>
</div> </div>
@@ -538,10 +550,16 @@ import { formatDuration } from '$lib/utils/format';
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
{#each booking.payments as payment, index (index)} {#each booking.payments as payment, index (index)}
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3"> <div
class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3"
>
<div class="flex-1"> <div class="flex-1">
<div class="font-medium"> <div class="font-medium">
{payment.payment_type === 'deposit' ? 'Deposit' : payment.payment_type === 'full' ? 'Full Payment' : payment.payment_type} {payment.payment_type === 'deposit'
? 'Deposit'
: payment.payment_type === 'full'
? 'Full Payment'
: payment.payment_type}
{#if payment.payment_method} {#if payment.payment_method}
<span class="text-gray-500"> via {payment.payment_method}</span> <span class="text-gray-500"> via {payment.payment_method}</span>
{/if} {/if}
@@ -550,7 +568,8 @@ import { formatDuration } from '$lib/utils/format';
<span <span
class:text-green-600={payment.status === 'completed'} class:text-green-600={payment.status === 'completed'}
class:text-amber-600={payment.status === 'pending'} class:text-amber-600={payment.status === 'pending'}
class:text-red-600={payment.status === 'failed' || payment.status === 'refunded'} class:text-red-600={payment.status === 'failed' ||
payment.status === 'refunded'}
> >
{payment.status} {payment.status}
</span> </span>
@@ -562,7 +581,11 @@ import { formatDuration } from '$lib/utils/format';
{/if} {/if}
</div> </div>
<div class="mt-1 text-xs text-gray-400"> <div class="mt-1 text-xs text-gray-400">
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} {new SvelteDate(payment.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</div> </div>
</div> </div>
{#if payment.status === 'completed'} {#if payment.status === 'completed'}
@@ -592,9 +615,7 @@ import { formatDuration } from '$lib/utils/format';
class="w-full" class="w-full"
/> />
{#if notes.trim() !== (booking.notes || '')} {#if notes.trim() !== (booking.notes || '')}
<div class="mt-1 text-xs text-emerald-600"> <div class="mt-1 text-xs text-emerald-600">Notes will be saved</div>
Notes will be saved
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -724,9 +745,7 @@ import { formatDuration } from '$lib/utils/format';
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto"> <Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header> <Modal.Header>
<Modal.Title class="text-lg font-semibold">Add Service</Modal.Title> <Modal.Title class="text-lg font-semibold">Add Service</Modal.Title>
<Modal.Description> <Modal.Description>Select a service to add to this booking.</Modal.Description>
Select a service to add to this booking.
</Modal.Description>
</Modal.Header> </Modal.Header>
<div class="space-y-4 px-4 pb-4"> <div class="space-y-4 px-4 pb-4">
@@ -742,7 +761,9 @@ import { formatDuration } from '$lib/utils/format';
{#if loadingServices} {#if loadingServices}
<div class="text-center text-sm text-gray-500">Loading services...</div> <div class="text-center text-sm text-gray-500">Loading services...</div>
{:else if filteredServices.length === 0} {:else if filteredServices.length === 0}
<div class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500"> <div
class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500"
>
{#if availableServices.length === 0} {#if availableServices.length === 0}
No services available. No services available.
{:else} {:else}
@@ -778,16 +799,12 @@ import { formatDuration } from '$lib/utils/format';
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto"> <Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header> <Modal.Header>
<Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title> <Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title>
<Modal.Description> <Modal.Description>Enter the refund amount and reason.</Modal.Description>
Enter the refund amount and reason.
</Modal.Description>
</Modal.Header> </Modal.Header>
<div class="space-y-4 px-4 pb-4"> <div class="space-y-4 px-4 pb-4">
<div> <div>
<label for="refund-amount" class="mb-1 block text-xs text-gray-600"> <label for="refund-amount" class="mb-1 block text-xs text-gray-600"> Refund Amount </label>
Refund Amount
</label>
<div class="relative"> <div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">£</span> <span class="text-gray-500">£</span>
@@ -819,7 +836,10 @@ import { formatDuration } from '$lib/utils/format';
<Modal.Footer class="flex items-center justify-end gap-2"> <Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (showRefundModal = false)}>Cancel</Button> <Button variant="outline" onclick={() => (showRefundModal = false)}>Cancel</Button>
<Button onclick={processRefund} disabled={refundLoading || !refundAmount || !refundReason.trim()}> <Button
onclick={processRefund}
disabled={refundLoading || !refundAmount || !refundReason.trim()}
>
{refundLoading ? 'Processing...' : 'Confirm Refund'} {refundLoading ? 'Processing...' : 'Confirm Refund'}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
@@ -221,7 +221,10 @@
{formatDateLine1(editRequest.original.start_time)} {formatDateLine1(editRequest.original.start_time)}
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
{formatDateLine2(editRequest.original.start_time, getDuration(editRequest.original.services))} {formatDateLine2(
editRequest.original.start_time,
getDuration(editRequest.original.services)
)}
</div> </div>
</div> </div>
{#if isTimeChanged()} {#if isTimeChanged()}
@@ -231,11 +234,14 @@
{formatDateLine1(editRequest.proposed.start_time!)} {formatDateLine1(editRequest.proposed.start_time!)}
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
{formatDateLine2(editRequest.proposed.start_time!, getDuration(editRequest.proposed.services))} {formatDateLine2(
editRequest.proposed.start_time!,
getDuration(editRequest.proposed.services)
)}
</div> </div>
</div> </div>
{:else} {:else}
<div class="text-sm italic text-gray-500">No change</div> <div class="text-sm text-gray-500 italic">No change</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -252,7 +258,7 @@
{#each editRequest.original.services as service (service.id)} {#each editRequest.original.services as service (service.id)}
{#if serviceDiff.removed.some((s) => s.id === service.id)} {#if serviceDiff.removed.some((s) => s.id === service.id)}
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2"> <div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
<span class="mt-0.5 text-red-600 font-mono text-sm"></span> <span class="mt-0.5 font-mono text-sm text-red-600"></span>
<div class="flex-1"> <div class="flex-1">
<div class="text-sm font-medium text-red-700 line-through"> <div class="text-sm font-medium text-red-700 line-through">
{service.name} {service.name}
@@ -264,7 +270,7 @@
</div> </div>
{:else} {:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2"> <div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 text-gray-400 font-mono text-sm"> </span> <span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
<div class="flex-1"> <div class="flex-1">
<div class="text-sm font-medium">{service.name}</div> <div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600"> <div class="text-xs text-gray-600">
@@ -281,8 +287,10 @@
<div class="space-y-2"> <div class="space-y-2">
{#each editRequest.proposed.services as service (service.id)} {#each editRequest.proposed.services as service (service.id)}
{#if serviceDiff.added.some((s) => s.id === service.id)} {#if serviceDiff.added.some((s) => s.id === service.id)}
<div class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"> <div
<span class="mt-0.5 text-emerald-600 font-mono text-sm">+</span> class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
>
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
<div class="flex-1"> <div class="flex-1">
<div class="text-sm font-medium text-emerald-700">{service.name}</div> <div class="text-sm font-medium text-emerald-700">{service.name}</div>
<div class="text-xs text-emerald-600"> <div class="text-xs text-emerald-600">
@@ -292,7 +300,7 @@
</div> </div>
{:else} {:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2"> <div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 text-gray-400 font-mono text-sm"> </span> <span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
<div class="flex-1"> <div class="flex-1">
<div class="text-sm font-medium">{service.name}</div> <div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600"> <div class="text-xs text-gray-600">
@@ -322,7 +330,7 @@
{#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes} {#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes}
<div class="text-sm">{editRequest.proposed.notes}</div> <div class="text-sm">{editRequest.proposed.notes}</div>
{:else} {:else}
<div class="text-sm italic text-gray-500">No change</div> <div class="text-sm text-gray-500 italic">No change</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -340,11 +348,7 @@
</div> </div>
<Modal.Footer class="flex items-center justify-end gap-2"> <Modal.Footer class="flex items-center justify-end gap-2">
<Button <Button variant="destructive" onclick={() => (showDenyConfirm = true)} disabled={submitting}>
variant="destructive"
onclick={() => (showDenyConfirm = true)}
disabled={submitting}
>
Deny Deny
</Button> </Button>
<Button <Button
@@ -754,24 +754,26 @@
/> />
</div> </div>
{#if input.length && suggestions.length} {#if input.length && suggestions.length}
<div class="absolute left-0 right-0 top-full z-10 mt-1 rounded-md border bg-white shadow-md">
{#each suggestions as s, i (s)}
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
<div <div
class="cursor-pointer px-3 py-3 text-sm touch-manipulation {isHighlighted class="absolute top-full right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md"
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-gray-100'}"
onclick={() => selectSuggestion(s)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectSuggestion(s)}
role="button"
tabindex="0"
> >
{s} {#each suggestions as s, i (s)}
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
<div
class="cursor-pointer touch-manipulation px-3 py-3 text-sm {isHighlighted
? 'bg-primary/10 font-medium text-primary'
: 'hover:bg-gray-100'}"
onclick={() => selectSuggestion(s)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectSuggestion(s)}
role="button"
tabindex="0"
>
{s}
</div>
{/each}
</div> </div>
{/each} {/if}
</div>
{/if}
</div> </div>
</div> </div>
@@ -82,12 +82,12 @@
const text = await response.text(); const text = await response.text();
toast.error(text || 'Failed to add patch test', { id: loadingToast }); toast.error(text || 'Failed to add patch test', { id: loadingToast });
} }
} catch (err) { } catch (err) {
console.error('Error adding patch test:', err); console.error('Error adding patch test:', err);
toast.error('Network error', { id: loadingToast }); toast.error('Network error', { id: loadingToast });
} finally { } finally {
submitting = false; submitting = false;
} }
} }
$effect(() => { $effect(() => {
@@ -33,7 +33,11 @@
let saving = $state(false); let saving = $state(false);
let placeholder = $state<CalendarDate>( let placeholder = $state<CalendarDate>(
new CalendarDate(new SvelteDate().getFullYear(), new SvelteDate().getMonth() + 1, new SvelteDate().getDate()) new CalendarDate(
new SvelteDate().getFullYear(),
new SvelteDate().getMonth() + 1,
new SvelteDate().getDate()
)
); );
let selectedDate = $state<CalendarDate | undefined>(undefined); let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null); let selectedTime = $state<string | null>(null);
@@ -55,21 +59,42 @@
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate()); const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new SvelteDate(); const maxDate = new SvelteDate();
maxDate.setMonth(today.getMonth() + 6); maxDate.setMonth(today.getMonth() + 6);
const maxCalendarDate = new CalendarDate(maxDate.getFullYear(), maxDate.getMonth() + 1, maxDate.getDate()); const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
maxDate.getMonth() + 1,
maxDate.getDate()
);
let bookingDuration = $derived( let bookingDuration = $derived(
booking.services?.reduce((sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0), 0) ?? booking.duration_minutes ?? 0 booking.services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
0
) ??
booking.duration_minutes ??
0
); );
const lunchProtection = $derived( const lunchProtection = $derived(
selectedDate ? buildLunchProtection(selectedDate, workingHours, availableHours, bookingDuration, true) : new Map() selectedDate
? buildLunchProtection(selectedDate, workingHours, availableHours, bookingDuration, true)
: new Map()
); );
const groupedTimeSlots = $derived( const groupedTimeSlots = $derived(
selectedDate ? generateGroupedTimeSlots(selectedDate, workingHours, availableHours, bookingDuration, lunchProtection) : [] selectedDate
? generateGroupedTimeSlots(
selectedDate,
workingHours,
availableHours,
bookingDuration,
lunchProtection
)
: []
); );
const formattedSelectedDate = $derived(selectedDate ? getDayWithOrdinal(selectedDate) : undefined); const formattedSelectedDate = $derived(
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
);
function isDateUnavailable(date: DateValue): boolean { function isDateUnavailable(date: DateValue): boolean {
if (!(date instanceof CalendarDate)) return true; if (!(date instanceof CalendarDate)) return true;
@@ -84,8 +109,20 @@
// If booking duration is 0 (data missing), no valid slots can exist — mark unavailable // If booking duration is 0 (data missing), no valid slots can exist — mark unavailable
if (bookingDuration <= 0) return true; if (bookingDuration <= 0) return true;
// Build lunch protection specifically for the date being checked (not the selected date) // Build lunch protection specifically for the date being checked (not the selected date)
const dayProtection = buildLunchProtection(date as CalendarDate, workingHours, availableHours, bookingDuration, true); const dayProtection = buildLunchProtection(
const slots = generateAvailableTimeSlots(date as CalendarDate, workingHours, availableHours, bookingDuration, dayProtection); date as CalendarDate,
workingHours,
availableHours,
bookingDuration,
true
);
const slots = generateAvailableTimeSlots(
date as CalendarDate,
workingHours,
availableHours,
bookingDuration,
dayProtection
);
if (slots.length === 0) return true; if (slots.length === 0) return true;
return false; return false;
} }
@@ -130,7 +167,9 @@
const whMap: Record<string, DayHours> = {}; const whMap: Record<string, DayHours> = {};
const ahMap: Record<string, DayAvailability> = {}; const ahMap: Record<string, DayAvailability> = {};
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })); whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
);
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots })); ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
// Cache by month key // Cache by month key
@@ -191,7 +230,11 @@
try { try {
const startOfMonth = new CalendarDate(date.year, date.month, 1); const startOfMonth = new CalendarDate(date.year, date.month, 1);
const endOfMonth = new CalendarDate(date.year, date.month, date.calendar.getDaysInMonth(date)); const endOfMonth = new CalendarDate(
date.year,
date.month,
date.calendar.getDaysInMonth(date)
);
const [whRes, ahRes] = await Promise.all([ const [whRes, ahRes] = await Promise.all([
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, { fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` } headers: { Authorization: `Bearer ${authStore.currentToken}` }
@@ -206,7 +249,9 @@
if (gen !== hoursMonthGeneration) return; if (gen !== hoursMonthGeneration) return;
const whMap: Record<string, DayHours> = {}; const whMap: Record<string, DayHours> = {};
const ahMap: Record<string, DayAvailability> = {}; const ahMap: Record<string, DayAvailability> = {};
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })); whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
);
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots })); ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
workingHoursCache[monthKey] = whMap; workingHoursCache[monthKey] = whMap;
@@ -220,7 +265,7 @@
toast.error('Failed to load availability'); toast.error('Failed to load availability');
} finally { } finally {
delete loadingMonths[monthKey]; delete loadingMonths[monthKey];
loadingMonthKeys = new Set([...loadingMonthKeys].filter(k => k !== monthKey)); loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey));
loadingHours = false; loadingHours = false;
} }
} }
@@ -237,7 +282,11 @@
loadingMonths = {}; loadingMonths = {};
loadingMonthKeys = new Set(); loadingMonthKeys = new Set();
rescheduleAutoSelectDone = false; rescheduleAutoSelectDone = false;
placeholder = new CalendarDate(new SvelteDate().getFullYear(), new SvelteDate().getMonth() + 1, new SvelteDate().getDate()); placeholder = new CalendarDate(
new SvelteDate().getFullYear(),
new SvelteDate().getMonth() + 1,
new SvelteDate().getDate()
);
} }
async function reschedule() { async function reschedule() {
@@ -318,7 +367,14 @@
// Auto-select first available date once data loads (timing-safe, data-driven) // Auto-select first available date once data loads (timing-safe, data-driven)
$effect(() => { $effect(() => {
if (open && workingHours && availableHours && !selectedDate && !userNavigatedCalendar && !rescheduleAutoSelectDone) { if (
open &&
workingHours &&
availableHours &&
!selectedDate &&
!userNavigatedCalendar &&
!rescheduleAutoSelectDone
) {
rescheduleAutoSelectDone = true; rescheduleAutoSelectDone = true;
const now = new SvelteDate(); const now = new SvelteDate();
const maxDateJs = new SvelteDate( const maxDateJs = new SvelteDate(
@@ -334,7 +390,11 @@
const checkDate = new SvelteDate(now); const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i); checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toISOString().split('T')[0]; const dateStr = checkDate.toISOString().split('T')[0];
const calDate = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, checkDate.getDate()); const calDate = new CalendarDate(
checkDate.getFullYear(),
checkDate.getMonth() + 1,
checkDate.getDate()
);
if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) { if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
selectedDate = calDate; selectedDate = calDate;
if (!userNavigatedCalendar) { if (!userNavigatedCalendar) {
@@ -360,10 +420,21 @@
<Card.Root class="mb-4"> <Card.Root class="mb-4">
<Card.Content class="pt-4"> <Card.Content class="pt-4">
<div class="text-sm font-medium"> <div class="text-sm font-medium">
Current: {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })} at {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit', hour12: true })} Current: {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})} at {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</div> </div>
<div class="text-sm text-gray-500 mt-1"> <div class="mt-1 text-sm text-gray-500">
{booking.user?.full_name || 'Unknown'} · {booking.services?.map((s) => s.service_name).join(', ') || 'No services'} · {bookingDuration} min {booking.user?.full_name || 'Unknown'} · {booking.services
?.map((s) => s.service_name)
.join(', ') || 'No services'} · {bookingDuration} min
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -381,10 +452,10 @@
selectedDate = newDate; selectedDate = newDate;
selectedTime = null; selectedTime = null;
}} }}
onPlaceholderChange={(newPlaceholder) => { onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder; placeholder = newPlaceholder;
userNavigatedCalendar = true; userNavigatedCalendar = true;
}} }}
/> />
</div> </div>
@@ -393,14 +464,18 @@
<div class="border-t"> <div class="border-t">
<div class="max-h-64 overflow-y-auto p-6"> <div class="max-h-64 overflow-y-auto p-6">
{#if formattedSelectedDate} {#if formattedSelectedDate}
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">{formattedSelectedDate}</div> <div class="mb-3 grid justify-center gap-2 text-sm font-medium">
{formattedSelectedDate}
</div>
{/if} {/if}
<TimeSlotList <TimeSlotList
slots={groupedTimeSlots} slots={groupedTimeSlots}
{selectedTime} {selectedTime}
duration={bookingDuration} duration={bookingDuration}
protection={lunchProtection} protection={lunchProtection}
onSelect={(time) => { selectedTime = time; }} onSelect={(time) => {
selectedTime = time;
}}
/> />
</div> </div>
</div> </div>
@@ -423,7 +498,14 @@
</div> </div>
<Modal.Footer class="flex items-center justify-end gap-2"> <Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => { open = false; resetForm(); }} disabled={saving}> <Button
variant="outline"
onclick={() => {
open = false;
resetForm();
}}
disabled={saving}
>
Cancel Cancel
</Button> </Button>
<Button onclick={reschedule} disabled={saving || !selectedDate || !selectedTime}> <Button onclick={reschedule} disabled={saving || !selectedDate || !selectedTime}>
@@ -129,11 +129,21 @@
if (!wh || !wh.isOpen) return []; if (!wh || !wh.isOpen) return [];
const startMin = timeToMinutes(wh.startTime); const startMin = timeToMinutes(wh.startTime);
const endMin = timeToMinutes(wh.endTime); const endMin = timeToMinutes(wh.endTime);
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = []; const options: Array<{
hour: string;
minute: string;
period: 'AM' | 'PM';
totalMin: number;
label: string;
}> = [];
for (let m = startMin; m < endMin; m += 15) { for (let m = startMin; m < endMin; m += 15) {
const t = minutesTo12h(m); const t = minutesTo12h(m);
const isStart = m === startMin; const isStart = m === startMin;
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isStart ? ' (start of day)' : ''}` }); options.push({
...t,
totalMin: m,
label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isStart ? ' (start of day)' : ''}`
});
} }
return options; return options;
}); });
@@ -144,17 +154,25 @@
const startMin = timeToMinutes(wh.startTime); const startMin = timeToMinutes(wh.startTime);
const endMin = timeToMinutes(wh.endTime); const endMin = timeToMinutes(wh.endTime);
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod)); const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = []; const options: Array<{
hour: string;
minute: string;
period: 'AM' | 'PM';
totalMin: number;
label: string;
}> = [];
for (let m = Math.max(startMin, currentStartMin + 15); m <= endMin; m += 15) { for (let m = Math.max(startMin, currentStartMin + 15); m <= endMin; m += 15) {
const t = minutesTo12h(m); const t = minutesTo12h(m);
const isEnd = m === endMin; const isEnd = m === endMin;
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isEnd ? ' (end of day)' : ''}` }); options.push({
...t,
totalMin: m,
label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isEnd ? ' (end of day)' : ''}`
});
} }
return options; return options;
}); });
function formatRelativeTime(iso: string): string { function formatRelativeTime(iso: string): string {
const d = new SvelteDate(iso); const d = new SvelteDate(iso);
const now = new SvelteDate(); const now = new SvelteDate();
@@ -174,7 +192,9 @@
} }
let sortedBlockers = $derived.by(() => { let sortedBlockers = $derived.by(() => {
return [...blockers].sort((a, b) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()); return [...blockers].sort(
(a, b) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
);
}); });
let totalPages = $derived.by(() => Math.max(1, Math.ceil(sortedBlockers.length / PAGE_SIZE))); let totalPages = $derived.by(() => Math.max(1, Math.ceil(sortedBlockers.length / PAGE_SIZE)));
@@ -233,14 +253,21 @@
} }
} }
function buildDateTime(date: string, hour: string, minute: string, period: 'AM' | 'PM'): string | null { function buildDateTime(
date: string,
hour: string,
minute: string,
period: 'AM' | 'PM'
): string | null {
if (!date) return null; if (!date) return null;
const t24 = to24h(hour, minute, period); const t24 = to24h(hour, minute, period);
return `${date}T${t24}:00`; return `${date}T${t24}:00`;
} }
let formComplete = $derived.by(() => { let formComplete = $derived.by(() => {
return newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== ''; return (
newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== ''
);
}); });
let canCreate = $derived.by(() => { let canCreate = $derived.by(() => {
@@ -513,128 +540,135 @@
<Skeleton class="h-16 w-full" /> <Skeleton class="h-16 w-full" />
{/each} {/each}
</div> </div>
{:else if blockers.length === 0}
<p class="text-sm text-gray-500">No time blockers found.</p>
{:else} {:else}
{#if blockers.length === 0} <div class="space-y-3">
<p class="text-sm text-gray-500">No time blockers found.</p> {#each pagedBlockers as b (b.id)}
{:else} <div
<div class="space-y-3"> class="flex items-center justify-between gap-3 rounded-lg border p-3 transition-all hover:shadow-sm sm:p-4"
{#each pagedBlockers as b (b.id)} >
<div class="flex items-center justify-between gap-3 rounded-lg border p-3 sm:p-4 transition-all hover:shadow-sm"> <div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex min-w-0 flex-1 items-center gap-3"> <div class="hidden shrink-0 rounded-lg bg-gray-50 p-1.5 sm:block">
<div class="hidden shrink-0 rounded-lg bg-gray-50 p-1.5 sm:block">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div class="min-w-0">
<div class="truncate font-medium text-gray-900">{b.description || 'Untitled'}</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-gray-600">
<span>
{new SvelteDate(b.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short'
})}
</span>
<span class="text-gray-400">·</span>
<span>
{new SvelteDate(b.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
<span class="text-gray-400"></span>
<span>
{(() => {
const end = new SvelteDate(b.start_time);
end.setMinutes(end.getMinutes() + b.duration_minutes);
return end.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
})()}
</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
{formatDuration(b.duration_minutes)}
</span>
</div>
<div class="text-xs text-gray-400">
{formatRelativeTime(b.start_time)}
</div>
</div>
</div>
<Button
variant="destructive"
size="sm"
class="shrink-0"
onclick={() => {
blockerToDelete = b;
showDeleteAlert = true;
}}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3" class="h-4 w-4 text-gray-600"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
> >
<polyline points="3 6 5 6 21 6" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
</Button> </div>
<div class="min-w-0">
<div class="truncate font-medium text-gray-900">{b.description || 'Untitled'}</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-gray-600">
<span>
{new SvelteDate(b.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short'
})}
</span>
<span class="text-gray-400">·</span>
<span>
{new SvelteDate(b.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
<span class="text-gray-400"></span>
<span>
{(() => {
const end = new SvelteDate(b.start_time);
end.setMinutes(end.getMinutes() + b.duration_minutes);
return end.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
})()}
</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
{formatDuration(b.duration_minutes)}
</span>
</div>
<div class="text-xs text-gray-400">
{formatRelativeTime(b.start_time)}
</div>
</div>
</div> </div>
{/each} <Button
</div> variant="destructive"
size="sm"
{#if totalPages > 1} class="shrink-0"
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between pt-2"> onclick={() => {
<p class="text-sm text-gray-500"> blockerToDelete = b;
{(currentPage - 1) * PAGE_SIZE + 1}{Math.min(currentPage * PAGE_SIZE, sortedBlockers.length)} of {sortedBlockers.length} showDeleteAlert = true;
</p> }}
<div class="flex items-center gap-1"> >
<Button <svg
variant="outline" xmlns="http://www.w3.org/2000/svg"
size="sm" class="h-3 w-3"
disabled={currentPage <= 1} viewBox="0 0 24 24"
onclick={() => (currentPage -= 1)} fill="none"
stroke="currentColor"
stroke-width="2"
> >
Prev <polyline points="3 6 5 6 21 6" />
</Button> <path
{#each Array(totalPages) as _, i (i)} d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
<Button />
variant={currentPage === i + 1 ? 'default' : 'outline'} </svg>
size="sm" </Button>
class="h-8 w-8 p-0"
onclick={() => (currentPage = i + 1)}
>
{i + 1}
</Button>
{/each}
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onclick={() => (currentPage += 1)}
>
Next
</Button>
</div>
</div> </div>
{/if} {/each}
</div>
{#if totalPages > 1}
<div
class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-sm text-gray-500">
{(currentPage - 1) * PAGE_SIZE + 1}{Math.min(
currentPage * PAGE_SIZE,
sortedBlockers.length
)} of {sortedBlockers.length}
</p>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onclick={() => (currentPage -= 1)}
>
Prev
</Button>
{#each Array(totalPages) as _, i (i)}
<Button
variant={currentPage === i + 1 ? 'default' : 'outline'}
size="sm"
class="h-8 w-8 p-0"
onclick={() => (currentPage = i + 1)}
>
{i + 1}
</Button>
{/each}
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onclick={() => (currentPage += 1)}
>
Next
</Button>
</div>
</div>
{/if} {/if}
{/if} {/if}
</Card.Content> </Card.Content>
@@ -668,14 +702,37 @@
<div class="space-y-2"> <div class="space-y-2">
<label for="blocker-start-date" class="text-xs text-gray-600">Date</label> <label for="blocker-start-date" class="text-xs text-gray-600">Date</label>
<div class="flex gap-2"> <div class="flex gap-2">
<Button type="button" variant="outline" size="sm" class="text-xs flex-1" onclick={() => { newStartDate = new Date().toISOString().slice(0, 10); onStartDateChange(); }}> <Button
type="button"
variant="outline"
size="sm"
class="flex-1 text-xs"
onclick={() => {
newStartDate = new Date().toISOString().slice(0, 10);
onStartDateChange();
}}
>
Today Today
</Button> </Button>
<Button type="button" variant="outline" size="sm" class="text-xs flex-1" onclick={() => { newStartDate = new Date(Date.now() + 86400000).toISOString().slice(0, 10); onStartDateChange(); }}> <Button
type="button"
variant="outline"
size="sm"
class="flex-1 text-xs"
onclick={() => {
newStartDate = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
onStartDateChange();
}}
>
Tomorrow Tomorrow
</Button> </Button>
</div> </div>
<Input id="blocker-start-date" type="date" bind:value={newStartDate} onchange={onStartDateChange} /> <Input
id="blocker-start-date"
type="date"
bind:value={newStartDate}
onchange={onStartDateChange}
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs text-gray-600">Time</label> <label class="text-xs text-gray-600">Time</label>
@@ -684,7 +741,11 @@
{:else if !selectedWorkingHours} {:else if !selectedWorkingHours}
<p class="text-sm text-gray-400">Select a date first</p> <p class="text-sm text-gray-400">Select a date first</p>
{:else if !selectedWorkingHours.isOpen} {:else if !selectedWorkingHours.isOpen}
<p class="text-sm text-red-500">Closed on {new SvelteDate(newStartDate + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'long' })}</p> <p class="text-sm text-red-500">
Closed on {new SvelteDate(newStartDate + 'T00:00:00').toLocaleDateString('en-GB', {
weekday: 'long'
})}
</p>
{:else} {:else}
<select <select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
@@ -744,28 +805,51 @@
{#if checkingOverlap} {#if checkingOverlap}
<div class="flex items-center gap-2 text-sm text-gray-500"> <div class="flex items-center gap-2 text-sm text-gray-500">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"> <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> <circle
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg> </svg>
Checking for conflicting bookings… Checking for conflicting bookings…
</div> </div>
{:else if hasOverlap} {:else if hasOverlap}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4"> <div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-2 mb-3"> <div class="mb-3 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /> xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
<span class="text-sm font-medium text-amber-800"> <span class="text-sm font-medium text-amber-800">
{overlappingBookings.length} booking{overlappingBookings.length > 1 ? 's' : ''} conflict{overlappingBookings.length > 1 ? '' : 's'} with this slot {overlappingBookings.length} booking{overlappingBookings.length > 1 ? 's' : ''} conflict{overlappingBookings.length >
1
? ''
: 's'} with this slot
</span> </span>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
{#each overlappingBookings as booking (booking.id)} {#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-3"> <div class="rounded-md border border-amber-200 bg-white p-3">
<div class="min-w-0"> <div class="min-w-0">
<div class="font-medium text-sm">{booking.user?.full_name || 'Unknown'}</div> <div class="text-sm font-medium">{booking.user?.full_name || 'Unknown'}</div>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric', hour: 'numeric',
@@ -780,12 +864,12 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex gap-2 mt-2"> <div class="mt-2 flex gap-2">
{#if openBookingModal} {#if openBookingModal}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="text-xs h-7" class="h-7 text-xs"
onclick={() => openBookingModal(booking.id)} onclick={() => openBookingModal(booking.id)}
> >
View Booking View Booking
@@ -795,7 +879,7 @@
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="text-xs h-7" class="h-7 text-xs"
onclick={() => openUserModal(booking.user!.id)} onclick={() => openUserModal(booking.user!.id)}
> >
View Client View Client
@@ -832,8 +916,8 @@
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title> <AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
This will remove the "{blockerToDelete?.description}" time blocker. Bookings may become available This will remove the "{blockerToDelete?.description}" time blocker. Bookings may become
during this period. This action cannot be undone. available during this period. This action cannot be undone.
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
@@ -230,7 +230,9 @@
</script> </script>
<Modal.Root bind:open> <Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"> <Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
>
<Modal.Header> <Modal.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -270,7 +272,9 @@
</div> </div>
<div> <div>
<div class="text-xs text-gray-500">Email</div> <div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-words" title={selectedUser.email}>{selectedUser.email || '—'}</div> <div class="font-medium break-words" title={selectedUser.email}>
{selectedUser.email || '—'}
</div>
</div> </div>
<div> <div>
<div class="text-xs text-gray-500">Phone</div> <div class="text-xs text-gray-500">Phone</div>
@@ -334,7 +338,7 @@
<img <img
src={selectedUser.profilePicUrl} src={selectedUser.profilePicUrl}
alt="Profile" alt="Profile"
class="mt-2 h-20 w-20 md:h-24 md:w-24 rounded-lg object-cover" class="mt-2 h-20 w-20 rounded-lg object-cover md:h-24 md:w-24"
/> />
</div> </div>
{/if} {/if}
@@ -381,50 +385,50 @@
return `${dateStr} at ${timeStr}`; return `${dateStr} at ${timeStr}`;
})()} })()}
</div> </div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs"> <div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{booking.status === 'confirmed' || booking.status === 'in_progress' {booking.status === 'confirmed' || booking.status === 'in_progress'
? 'bg-emerald-100 text-emerald-800' ? 'bg-emerald-100 text-emerald-800'
: booking.status === 'pending' : booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800' ? 'bg-yellow-100 text-yellow-800'
: booking.status === 'completed' : booking.status === 'completed'
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: booking.status === 'client_cancelled' || : booking.status === 'client_cancelled' ||
booking.status === 'we_cancelled' || booking.status === 'we_cancelled' ||
booking.status === 'no_deposit' booking.status === 'no_deposit'
? 'bg-red-100 text-red-800' ? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}" : 'bg-gray-100 text-gray-800'}"
> >
{booking.status.replace('_', ' ')} {booking.status.replace('_', ' ')}
</span> </span>
{#if booking.deposit_required} {#if booking.deposit_required}
{#if booking.status === 'pending'} {#if booking.status === 'pending'}
<span <span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800" class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
> >
Will Require Deposit Will Require Deposit
</span> </span>
{:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid} {:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid}
<span <span
class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800" class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800"
> >
Deposit Due Deposit Due
</span> </span>
{:else if booking.deposit_paid} {:else if booking.deposit_paid}
<span <span
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800" class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
> >
Deposit Paid Deposit Paid
</span> </span>
{/if}
{/if} {/if}
{/if}
<span class="text-gray-500"> <span class="text-gray-500">
{booking.services.map((s) => s.service_name).join(', ')} {booking.services.map((s) => s.service_name).join(', ')}
</span> </span>
</div> </div>
<div class="mt-1 text-sm font-semibold text-gray-900"> <div class="mt-1 text-sm font-semibold text-gray-900">
£{booking.total_amount.toFixed(2)} £{booking.total_amount.toFixed(2)}
</div> </div>
@@ -484,19 +488,27 @@
<div class="grid gap-3 md:grid-cols-3"> <div class="grid gap-3 md:grid-cols-3">
<div> <div>
<div class="text-xs text-gray-500">Total Spend</div> <div class="text-xs text-gray-500">Total Spend</div>
<div class="text-2xl font-bold text-emerald-600">£{customerRelationship.totalSpend.toFixed(2)}</div> <div class="text-2xl font-bold text-emerald-600">
£{customerRelationship.totalSpend.toFixed(2)}
</div>
</div> </div>
<div> <div>
<div class="text-xs text-gray-500">Total Tips</div> <div class="text-xs text-gray-500">Total Tips</div>
<div class="text-2xl font-bold text-amber-600">£{customerRelationship.totalTips.toFixed(2)}</div> <div class="text-2xl font-bold text-amber-600">
£{customerRelationship.totalTips.toFixed(2)}
</div>
</div> </div>
<div> <div>
<div class="text-xs text-gray-500">Total Visits</div> <div class="text-xs text-gray-500">Total Visits</div>
<div class="text-2xl font-bold text-blue-600">{customerRelationship.totalVisits}</div> <div class="text-2xl font-bold text-blue-600">
{customerRelationship.totalVisits}
</div>
</div> </div>
<div> <div>
<div class="text-xs text-gray-500">Customer For</div> <div class="text-xs text-gray-500">Customer For</div>
<div class="text-2xl font-bold text-purple-600">{customerRelationship.customerFor || '—'}</div> <div class="text-2xl font-bold text-purple-600">
{customerRelationship.customerFor || '—'}
</div>
</div> </div>
</div> </div>
@@ -505,7 +517,9 @@
<div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div> <div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each customerRelationship.topServices as service (service.name)} {#each customerRelationship.topServices as service (service.name)}
<span class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"> <span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
{service.name} {service.name}
<span class="ml-1 text-xs text-blue-600">({service.count})</span> <span class="ml-1 text-xs text-blue-600">({service.count})</span>
</span> </span>
@@ -538,21 +552,28 @@
</div> </div>
{#if hasEligiblePatchTests} {#if hasEligiblePatchTests}
<!-- Patch Test Actions --> <!-- Patch Test Actions -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Patch Tests Patch Tests
</h3> </h3>
<p class="mb-3 text-sm text-gray-600"> <p class="mb-3 text-sm text-gray-600">
Record patch test completion to allow this user to book services requiring one. Record patch test completion to allow this user to book services requiring one.
</p> </p>
<Button variant="outline" onclick={() => (showPatchTestModal = true)}> <Button variant="outline" onclick={() => (showPatchTestModal = true)}>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/> xmlns="http://www.w3.org/2000/svg"
</svg> class="mr-2 h-4 w-4"
Record Patch Test viewBox="0 0 24 24"
</Button> fill="none"
</div> stroke="currentColor"
stroke-width="2"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Record Patch Test
</Button>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -38,9 +38,7 @@
}); });
if (response.ok) { if (response.ok) {
const services: Service[] = await response.json(); const services: Service[] = await response.json();
const durations = services const durations = services.map((s) => s.duration_minutes).filter((d) => d > 0);
.map((s) => s.duration_minutes)
.filter((d) => d > 0);
if (durations.length > 0) { if (durations.length > 0) {
shortestServiceMinutes = Math.min(...durations); shortestServiceMinutes = Math.min(...durations);
} }
@@ -355,14 +353,19 @@
{#if tooShortForService} {#if tooShortForService}
<p class="mb-4 text-sm text-amber-600"> <p class="mb-4 text-sm text-amber-600">
Not enough time for any service{shortestServiceMinutes !== null ? ` (shortest service is ${formatDuration(shortestServiceMinutes)})` : ''} Not enough time for any service{shortestServiceMinutes !== null
? ` (shortest service is ${formatDuration(shortestServiceMinutes)})`
: ''}
</p> </p>
{/if} {/if}
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<Button <Button
onclick={handleStartWalkIn} onclick={handleStartWalkIn}
disabled={noSlotsToday || isReserving || (slotInfo?.isAvailableNow && (getLiveRemainingMinutes() ?? 0) <= 0) || tooShortForService} disabled={noSlotsToday ||
isReserving ||
(slotInfo?.isAvailableNow && (getLiveRemainingMinutes() ?? 0) <= 0) ||
tooShortForService}
> >
{isReserving ? 'Reserving...' : 'Start Walk-In Session'} {isReserving ? 'Reserving...' : 'Start Walk-In Session'}
</Button> </Button>
@@ -374,7 +377,7 @@
bind:open={showCreateModal} bind:open={showCreateModal}
maxSlotDuration={reservedDuration} maxSlotDuration={reservedDuration}
availableStartTime={undefined} availableStartTime={undefined}
reservationExpiresAt={reservationExpiresAt} {reservationExpiresAt}
onclose={handleModalClose} onclose={handleModalClose}
/> />
{/if} {/if}
@@ -107,9 +107,7 @@
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration); const isOverDuration = $derived(getTotalDuration() > maxSlotDuration);
const canProceedStep1 = $derived( const canProceedStep1 = $derived(userType === 'member' ? !!selectedUserId : !!guestName.trim());
userType === 'member' ? !!selectedUserId : !!guestName.trim()
);
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration); const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
// =============== Effects =============== // =============== Effects ===============
@@ -277,35 +275,35 @@
return; return;
} }
let finalUserId = selectedUserId; let finalUserId = selectedUserId;
if (userType === 'guest') { if (userType === 'guest') {
const phone = guestPhone.trim() || '+447700900000'; const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', { const createRes = await fetch('/api/users/guest', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}` Authorization: `Bearer ${authStore.currentToken}`
}, },
body: JSON.stringify({ body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Walk-in', firstName: guestName.trim().split(' ')[0] || 'Walk-in',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Guest', lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Guest',
phone: phone, phone: phone,
email: `walkin-${Date.now()}@guest.invalid` email: `walkin-${Date.now()}@guest.invalid`
}) })
}); });
if (!createRes.ok) { if (!createRes.ok) {
toast.error('Failed to create guest user'); toast.error('Failed to create guest user');
submitting = false; submitting = false;
return; return;
}
const guestUser = await createRes.json();
finalUserId = guestUser.id;
} }
const guestUser = await createRes.json(); if (!finalUserId) throw new Error('User ID required');
finalUserId = guestUser.id;
}
if (!finalUserId) throw new Error('User ID required');
// Use the available slot start time from the widget // Use the available slot start time from the widget
let start: Date; let start: Date;
@@ -414,7 +412,9 @@
</script> </script>
<Modal.Root bind:open> <Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl"> <Modal.Content
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl"
>
<Modal.Header> <Modal.Header>
<Modal.Title>Walk-In Booking</Modal.Title> <Modal.Title>Walk-In Booking</Modal.Title>
<Modal.Description> <Modal.Description>
@@ -425,26 +425,39 @@
<div class="px-6 pb-4"> <div class="px-6 pb-4">
<!-- Reservation Countdown Banner --> <!-- Reservation Countdown Banner -->
{#if reservationExpiresAt && !isReservationExpired} {#if reservationExpiresAt && !isReservationExpired}
<div class="mx-6 mt-4 rounded-lg bg-green-50 border border-green-200 p-3"> <div class="mx-6 mt-4 rounded-lg border border-green-200 bg-green-50 p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> class="h-5 w-5 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg> </svg>
<span class="text-sm font-medium text-green-800"> <span class="text-sm font-medium text-green-800"> Slot held for </span>
Slot held for
</span>
</div> </div>
<span class="text-sm font-mono font-semibold text-green-700"> <span class="font-mono text-sm font-semibold text-green-700">
{reservationCountdown} {reservationCountdown}
</span> </span>
</div> </div>
</div> </div>
{:else if isReservationExpired} {:else if isReservationExpired}
<div class="mx-6 mt-4 rounded-lg bg-red-50 border border-red-200 p-3"> <div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 p-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg> </svg>
<span class="text-sm font-medium text-red-800"> <span class="text-sm font-medium text-red-800">
Slot released — please re-check availability Slot released — please re-check availability
@@ -600,17 +613,17 @@
oninput={(e) => (guestName = e.currentTarget.value)} oninput={(e) => (guestName = e.currentTarget.value)}
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="guest-phone">Phone Number</Label> <Label for="guest-phone">Phone Number</Label>
<input <input
id="guest-phone" id="guest-phone"
type="tel" type="tel"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none" class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="07700 900000 (optional — helps us reach you if running late)" placeholder="07700 900000 (optional — helps us reach you if running late)"
value={guestPhone} value={guestPhone}
oninput={(e) => (guestPhone = e.currentTarget.value)} oninput={(e) => (guestPhone = e.currentTarget.value)}
/> />
</div> </div>
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800"> <p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
Booking as a guest creates a temporary record. Encourage them to sign up for Booking as a guest creates a temporary record. Encourage them to sign up for
loyalty benefits. loyalty benefits.
@@ -487,7 +487,9 @@
> >
{#each Array(24) as _, hour} {#each Array(24) as _, hour}
{#each TIME15 as min (min)} {#each TIME15 as min (min)}
<option value="{String(hour).padStart(2,'0')}:{min}">{String(hour).padStart(2,'0')}:{min}</option> <option value="{String(hour).padStart(2, '0')}:{min}"
>{String(hour).padStart(2, '0')}:{min}</option
>
{/each} {/each}
{/each} {/each}
</select> </select>
@@ -500,7 +502,9 @@
> >
{#each Array(24) as _, hour} {#each Array(24) as _, hour}
{#each TIME15 as min (min)} {#each TIME15 as min (min)}
<option value="{String(hour).padStart(2,'0')}:{min}">{String(hour).padStart(2,'0')}:{min}</option> <option value="{String(hour).padStart(2, '0')}:{min}"
>{String(hour).padStart(2, '0')}:{min}</option
>
{/each} {/each}
{/each} {/each}
</select> </select>
@@ -1358,7 +1358,12 @@
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p> <p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
</div> </div>
<StepIndicator {currentStep} steps={stepLabels} startAt={authStore.isAuthenticated ? 1 : 0} className="{currentStep === 0 ? 'md:hidden' : ''}" /> <StepIndicator
{currentStep}
steps={stepLabels}
startAt={authStore.isAuthenticated ? 1 : 0}
className={currentStep === 0 ? 'md:hidden' : ''}
/>
{#if currentStep === 0} {#if currentStep === 0}
<Card.Root class="border-fuchsia-200 bg-fuchsia-50"> <Card.Root class="border-fuchsia-200 bg-fuchsia-50">
@@ -1371,12 +1376,14 @@
Guest checkout does not receive loyalty stamps or seasonal discounts. Guest checkout does not receive loyalty stamps or seasonal discounts.
</p> </p>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4"> <div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4">
<Button onclick={() => goto('/login')} variant="outline" class="border-fuchsia-200 hover:bg-fuchsia-100"> <Button
onclick={() => goto('/login')}
variant="outline"
class="border-fuchsia-200 hover:bg-fuchsia-100"
>
Log In Log In
</Button> </Button>
<Button onclick={nextStep}> <Button onclick={nextStep}>Continue as Guest</Button>
Continue as Guest
</Button>
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -25,7 +25,7 @@
<Calendar <Calendar
type="single" type="single"
value={date} value={date}
placeholder={placeholder} {placeholder}
// preventDeselect tells bits-ui to never deselect an already-selected date // preventDeselect tells bits-ui to never deselect an already-selected date
// (which would fire onValueChange(undefined) and desync the $bindable() chain) // (which would fire onValueChange(undefined) and desync the $bindable() chain)
preventDeselect={true} preventDeselect={true}
@@ -15,13 +15,28 @@
<Separator class="my-4" /> <Separator class="my-4" />
{#if protection?.showWarning || protection?.isBlocked} {#if protection?.showWarning || protection?.isBlocked}
<div class="rounded-lg border {protection?.isBlocked ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50'} p-4"> <div
class="rounded-lg border {protection?.isBlocked
? 'border-red-200 bg-red-50'
: 'border-amber-200 bg-amber-50'} p-4"
>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 shrink-0 {protection?.isBlocked ? 'text-red-600' : 'text-amber-600'}" viewBox="0 0 20 20" fill="currentColor"> <svg
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> xmlns="http://www.w3.org/2000/svg"
class="mt-0.5 h-4 w-4 shrink-0 {protection?.isBlocked ? 'text-red-600' : 'text-amber-600'}"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg> </svg>
<div> <div>
<div class="text-sm font-medium {protection?.isBlocked ? 'text-red-800' : 'text-amber-800'}"> <div
class="text-sm font-medium {protection?.isBlocked ? 'text-red-800' : 'text-amber-800'}"
>
{protection?.isBlocked ? 'Lunch break conflict' : 'Lunch break warning'} {protection?.isBlocked ? 'Lunch break conflict' : 'Lunch break warning'}
</div> </div>
<div class="mt-1 text-sm {protection?.isBlocked ? 'text-red-700' : 'text-amber-700'}"> <div class="mt-1 text-sm {protection?.isBlocked ? 'text-red-700' : 'text-amber-700'}">
@@ -31,9 +46,15 @@
</div> </div>
</div> </div>
{/if} {/if}
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 {protection?.showWarning || protection?.isBlocked ? 'mt-3' : ''}"> <div
class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 {protection?.showWarning ||
protection?.isBlocked
? 'mt-3'
: ''}"
>
<div class="text-sm font-medium text-emerald-800"> <div class="text-sm font-medium text-emerald-800">
{#if protection?.isBlocked}Booking conflict{:else if protection?.showWarning}Lunch warning{:else}Time selected{/if} {#if protection?.isBlocked}Booking conflict{:else if protection?.showWarning}Lunch warning{:else}Time
selected{/if}
</div> </div>
<div class="mt-1 text-sm text-emerald-700"> <div class="mt-1 text-sm text-emerald-700">
{selectedDate} at {selectedTime} {selectedDate} at {selectedTime}
@@ -26,7 +26,7 @@
onclick={() => !isGrayedOut && onclick?.()} onclick={() => !isGrayedOut && onclick?.()}
disabled={isGrayedOut} disabled={isGrayedOut}
> >
<div class="flex flex-col justify-between h-full min-h-[6rem]"> <div class="flex h-full min-h-[6rem] flex-col justify-between">
<div> <div>
<h3 class="font-semibold">{service.name}</h3> <h3 class="font-semibold">{service.name}</h3>
{#if isGrayedOut} {#if isGrayedOut}
@@ -44,7 +44,7 @@
<p class="text-sm text-gray-600">{service.description}</p> <p class="text-sm text-gray-600">{service.description}</p>
{/if} {/if}
</div> </div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500"> <div class="mt-2 flex items-center justify-between text-sm text-gray-500">
<span>{service.duration_minutes} mins</span> <span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price}</span> <span class="font-semibold text-foreground">£{service.price}</span>
</div> </div>
@@ -7,7 +7,9 @@
const totalSteps = steps.length; const totalSteps = steps.length;
</script> </script>
<div class="mb-8 grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:flex lg:items-center lg:justify-center lg:space-x-4 {className}"> <div
class="mb-8 grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:flex lg:items-center lg:justify-center lg:space-x-4 {className}"
>
{#each steps as step, index (step)} {#each steps as step, index (step)}
{@const displayNumber = startAt + index} {@const displayNumber = startAt + index}
{@const isActive = displayNumber <= currentStep} {@const isActive = displayNumber <= currentStep}
@@ -27,7 +27,7 @@
title={p.warningMessage || 'Lunch protection'} title={p.warningMessage || 'Lunch protection'}
> >
{formatTime(slot.startTime)} {formatTime(slot.startTime)}
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span> <span class="ml-2 text-gray-500">- {formatTime(slot.endTime)}</span>
<span class="ml-auto text-xs text-gray-400">{duration} min</span> <span class="ml-auto text-xs text-gray-400">{duration} min</span>
</Button> </Button>
{:else} {:else}
@@ -36,20 +36,33 @@
onclick={() => onSelect(slot.startTime)} onclick={() => onSelect(slot.startTime)}
class={`w-full hover:bg-fuchsia-50 ${ class={`w-full hover:bg-fuchsia-50 ${
slot.startTime === selectedTime slot.startTime === selectedTime
? (p?.showWarning ? 'bg-fuchsia-200 border-amber-500' : 'bg-fuchsia-100') ? p?.showWarning
: (p?.showWarning ? 'border-amber-400 bg-amber-100' : '') ? 'border-amber-500 bg-fuchsia-200'
: 'bg-fuchsia-100'
: p?.showWarning
? 'border-amber-400 bg-amber-100'
: ''
}`} }`}
title={p?.warningMessage} title={p?.warningMessage}
> >
{#if p?.showWarning} {#if p?.showWarning}
<span class="mr-1 text-amber-500"> <span class="mr-1 text-amber-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> <svg
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg> </svg>
</span> </span>
{/if} {/if}
{formatTime(slot.startTime)} {formatTime(slot.startTime)}
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span> <span class="ml-2 text-gray-500">- {formatTime(slot.endTime)}</span>
<span class="ml-auto text-xs text-gray-400">{duration} min</span> <span class="ml-auto text-xs text-gray-400">{duration} min</span>
</Button> </Button>
{/if} {/if}
@@ -60,11 +73,11 @@
disabled disabled
> >
{formatTime(slot.startTime)} {formatTime(slot.startTime)}
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span> <span class="ml-2 text-gray-500">- {formatTime(slot.endTime)}</span>
</Button> </Button>
{/if} {/if}
{/each} {/each}
</div> </div>
{:else} {:else}
<p class="text-center text-sm text-gray-500 py-4">No available slots for this date</p> <p class="py-4 text-center text-sm text-gray-500">No available slots for this date</p>
{/if} {/if}
@@ -15,7 +15,17 @@
let { booking, onClose, onComplete }: Props = $props(); let { booking, onClose, onComplete }: Props = $props();
type PaymentStatus = 'idle' | 'selecting' | 'card-processing' | 'card-polling' | 'cash-entering' | 'cash-confirming' | 'gift-entering' | 'gift-confirming' | 'success' | 'error'; type PaymentStatus =
| 'idle'
| 'selecting'
| 'card-processing'
| 'card-polling'
| 'cash-entering'
| 'cash-confirming'
| 'gift-entering'
| 'gift-confirming'
| 'success'
| 'error';
type PaymentResult = { type PaymentResult = {
checkout_id: string; checkout_id: string;
@@ -84,9 +94,9 @@
let tipPercentages = $derived.by(() => { let tipPercentages = $derived.by(() => {
if (subtotal <= 0) return []; if (subtotal <= 0) return [];
return [ return [
{ pct: 10, amount: Math.round(subtotal * 0.10 * 100) / 100 }, { pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 }, { pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(subtotal * 0.20 * 100) / 100 }, { pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 }
]; ];
}); });
@@ -124,9 +134,7 @@
return service.override_price ?? service.price ?? 0; return service.override_price ?? service.price ?? 0;
} }
let subtotal = $derived( let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0));
(booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0)
);
let tipMultiplier = $derived( let tipMultiplier = $derived(
selectedTipPercent !== null selectedTipPercent !== null
@@ -415,8 +423,12 @@
}); });
</script> </script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()} onOpenAutoFocus={(e) => e.preventDefault()}> <Dialog.Root
<Dialog.Content class="max-w-lg max-h-[90vh] overflow-y-auto"> open={true}
onOpenChange={(open) => !open && handleClose()}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
<Dialog.Header> <Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title> <Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
</Dialog.Header> </Dialog.Header>
@@ -425,10 +437,16 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-md border border-gray-200 bg-gray-50 p-4"> <div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div> <div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
<div class="grid grid-cols-1 {(booking.services?.length ?? 0) > 1 ? 'sm:grid-cols-2' : ''} gap-3"> <div
class="grid grid-cols-1 {(booking.services?.length ?? 0) > 1
? 'sm:grid-cols-2'
: ''} gap-3"
>
{#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)} {#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)}
<div class="rounded-lg border bg-white p-3"> <div class="rounded-lg border bg-white p-3">
<div class="mb-2 font-medium text-sm">{service.service_name || 'Unknown Service'}</div> <div class="mb-2 text-sm font-medium">
{service.service_name || 'Unknown Service'}
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs text-gray-500">£</span> <span class="text-xs text-gray-500">£</span>
<input <input
@@ -436,7 +454,9 @@
inputmode="decimal" inputmode="decimal"
tabindex="-1" tabindex="-1"
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none" class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
value={serviceOverrides[service.service_id]?.price ?? (service.price?.toFixed(2) ?? '0.00')} value={serviceOverrides[service.service_id]?.price ??
service.price?.toFixed(2) ??
'0.00'}
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)} oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
/> />
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01} {#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
@@ -469,10 +489,22 @@
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<button <button
type="button" type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod === 'card' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}" class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
onclick={() => { selectedMethod = 'card'; status = 'selecting'; }} 'card'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'card';
status = 'selecting';
}}
> >
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" /> <rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" /> <line x1="1" y1="10" x2="23" y2="10" />
</svg> </svg>
@@ -480,10 +512,22 @@
</button> </button>
<button <button
type="button" type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod === 'cash' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}" class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
onclick={() => { selectedMethod = 'cash'; status = 'cash-entering'; }} 'cash'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'cash';
status = 'cash-entering';
}}
> >
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="1" x2="12" y2="23" /> <line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /> <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg> </svg>
@@ -491,10 +535,22 @@
</button> </button>
<button <button
type="button" type="button"
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod === 'giftcard' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}" class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
onclick={() => { selectedMethod = 'giftcard'; status = 'gift-entering'; }} 'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
}}
> >
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 12 20 22 4 22 4 12" /> <polyline points="20 12 20 22 4 22 4 12" />
<rect x="2" y="7" width="20" height="5" /> <rect x="2" y="7" width="20" height="5" />
<line x1="12" y1="22" x2="12" y2="7" /> <line x1="12" y1="22" x2="12" y2="7" />
@@ -509,19 +565,19 @@
<button <button
type="button" type="button"
class="text-sm text-gray-600 underline hover:text-gray-900" class="text-sm text-gray-600 underline hover:text-gray-900"
onclick={() => { selectedMethod = 'giftcard'; status = 'gift-entering'; }} onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
}}
> >
Pay with Gift Card Pay with Gift Card
</button> </button>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1"> <Button variant="ghost" onclick={handleClose} class="flex-1">Cancel</Button>
Cancel
</Button>
</div> </div>
</div> </div>
{:else if status === 'selecting'} {:else if status === 'selecting'}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4"> <div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
@@ -537,8 +593,8 @@
type="button" type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent === class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
tip.pct tip.pct
? 'border-input bg-fuchsia-100 text-foreground' ? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}" : 'border-input'}"
onclick={() => selectTipPercent(tip.pct)} onclick={() => selectTipPercent(tip.pct)}
> >
<div>{tip.pct}%</div> <div>{tip.pct}%</div>
@@ -547,7 +603,7 @@
{/each} {/each}
</div> </div>
<div class="relative"> <div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span> <span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input <Input
type="text" type="text"
inputmode="decimal" inputmode="decimal"
@@ -572,25 +628,20 @@
{/if} {/if}
<div class="flex gap-3"> <div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1"> <Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
Back <Button onclick={handleCardPayment} class="flex-1 bg-green-600 hover:bg-green-700">
</Button>
<Button
onclick={handleCardPayment}
class="flex-1 bg-green-600 hover:bg-green-700"
>
Charge Card Charge Card
</Button> </Button>
</div> </div>
</div> </div>
{:else if status === 'card-processing' || status === 'card-polling'} {:else if status === 'card-processing' || status === 'card-polling'}
<div class="flex flex-col items-center justify-center py-8"> <div class="flex flex-col items-center justify-center py-8">
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div> <div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p> <p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p>
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p> <p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
</div> </div>
{:else if status === 'cash-entering'} {:else if status === 'cash-entering'}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4"> <div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
@@ -599,11 +650,9 @@
</div> </div>
<div> <div>
<label for="cash-amount" class="text-sm font-medium text-gray-700"> <label for="cash-amount" class="text-sm font-medium text-gray-700"> Cash Received </label>
Cash Received
</label>
<div class="relative mt-1"> <div class="relative mt-1">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span> <span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input <Input
id="cash-amount" id="cash-amount"
type="text" type="text"
@@ -624,10 +673,7 @@
</div> </div>
{#if changeDue > 0} {#if changeDue > 0}
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-2">
<Checkbox <Checkbox id="keep-change" bind:checked={extraAsTip} />
id="keep-change"
bind:checked={extraAsTip}
/>
<label for="keep-change" class="text-sm text-green-700"> <label for="keep-change" class="text-sm text-green-700">
Keep {formatCurrency(changeDue)} as tip Keep {formatCurrency(changeDue)} as tip
</label> </label>
@@ -637,9 +683,7 @@
{/if} {/if}
<div class="flex gap-3"> <div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1"> <Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
Back
</Button>
<Button <Button
onclick={handleCashPayment} onclick={handleCashPayment}
class="flex-1 bg-green-600 hover:bg-green-700" class="flex-1 bg-green-600 hover:bg-green-700"
@@ -649,13 +693,13 @@
</Button> </Button>
</div> </div>
</div> </div>
{:else if status === 'cash-confirming'} {:else if status === 'cash-confirming'}
<div class="flex flex-col items-center justify-center py-8"> <div class="flex flex-col items-center justify-center py-8">
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div> <div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Processing cash payment...</p> <p class="text-lg font-medium text-gray-700">Processing cash payment...</p>
</div> </div>
{:else if status === 'gift-entering'} {:else if status === 'gift-entering'}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4"> <div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
@@ -664,9 +708,7 @@
</div> </div>
<div> <div>
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> <label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card ID </label>
Gift Card ID
</label>
<Input <Input
id="gift-card-id" id="gift-card-id"
type="text" type="text"
@@ -682,9 +724,7 @@
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1"> <Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
Back
</Button>
<Button <Button
onclick={handleGiftCardPayment} onclick={handleGiftCardPayment}
class="flex-1 bg-green-600 hover:bg-green-700" class="flex-1 bg-green-600 hover:bg-green-700"
@@ -694,28 +734,23 @@
</Button> </Button>
</div> </div>
</div> </div>
{:else if status === 'gift-confirming'} {:else if status === 'gift-confirming'}
<div class="flex flex-col items-center justify-center py-8"> <div class="flex flex-col items-center justify-center py-8">
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div> <div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Processing gift card...</p> <p class="text-lg font-medium text-gray-700">Processing gift card...</p>
</div> </div>
{:else if status === 'error' && error} {:else if status === 'error' && error}
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-md border border-red-200 bg-red-50 p-3"> <div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p> <p class="text-sm text-red-800">{error}</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1"> <Button variant="ghost" onclick={handleClose} class="flex-1">Close</Button>
Close <Button onclick={resetToSelect} class="flex-1">Try Again</Button>
</Button>
<Button onclick={resetToSelect} class="flex-1">
Try Again
</Button>
</div> </div>
</div> </div>
{:else if status === 'success' && paymentResult} {:else if status === 'success' && paymentResult}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex flex-col items-center justify-center py-4"> <div class="flex flex-col items-center justify-center py-4">
@@ -759,9 +794,7 @@
</div> </div>
</div> </div>
<Button onclick={handleClose} class="w-full"> <Button onclick={handleClose} class="w-full">Done</Button>
Done
</Button>
</div> </div>
{/if} {/if}
</Dialog.Content> </Dialog.Content>
@@ -92,32 +92,31 @@
let expiryParts = $derived(parseExpiryParts(newCardExpiry)); let expiryParts = $derived(parseExpiryParts(newCardExpiry));
let isExpiryInPast = $derived( let isExpiryInPast = $derived(
expiryParts !== null && (() => { expiryParts !== null &&
const expiryDate = new SvelteDate(expiryParts.year, expiryParts.month); (() => {
return expiryDate < new SvelteDate(); const expiryDate = new SvelteDate(expiryParts.year, expiryParts.month);
})() return expiryDate < new SvelteDate();
})()
); );
let hasInvalidMonth = $derived( let hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null
);
let cardFormValid = $derived( let cardFormValid = $derived(
newCardNumber.replace(/\s/g, '').length >= 13 && newCardNumber.replace(/\s/g, '').length >= 13 &&
expiryParts !== null && expiryParts !== null &&
newCardCVC.length >= 3 && newCardCVC.length >= 3 &&
!isExpiryInPast !isExpiryInPast
); );
let cardSelected = $derived( let cardSelected = $derived(
(selectedPaymentMethod !== null && paymentMethods.length > 0) || (selectedPaymentMethod !== null && paymentMethods.length > 0) ||
(showNewCardForm && cardFormValid) || (showNewCardForm && cardFormValid) ||
(paymentMethods.length === 0 && cardFormValid) (paymentMethods.length === 0 && cardFormValid)
); );
let cardValidationError = $derived( let cardValidationError = $derived(
!cardSelected ? ( !cardSelected
selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm ? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
? 'Please select a card' ? 'Please select a card'
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0 : newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
? 'Card number too short' ? 'Card number too short'
@@ -132,7 +131,7 @@
: paymentMethods.length === 0 && !showNewCardForm && newCardNumber.length === 0 : paymentMethods.length === 0 && !showNewCardForm && newCardNumber.length === 0
? 'Please enter card details' ? 'Please enter card details'
: 'Please complete all card fields' : 'Please complete all card fields'
) : null : null
); );
// Partial payment amount (in pounds, user enters) // Partial payment amount (in pounds, user enters)
@@ -143,9 +142,7 @@
let pollingInterval: ReturnType<typeof setInterval> | null = null; let pollingInterval: ReturnType<typeof setInterval> | null = null;
// Derived values // Derived values
let depositOutstanding = $derived( let depositOutstanding = $derived(booking.deposit_required && !booking.deposit_paid);
booking.deposit_required && !booking.deposit_paid
);
let totalPaid = $derived( let totalPaid = $derived(
booking.payments booking.payments
@@ -157,23 +154,21 @@
let isScenarioA = $derived(depositOutstanding); let isScenarioA = $derived(depositOutstanding);
let isScenarioB = $derived( let isScenarioB = $derived(!depositOutstanding && totalPaid < booking.total_amount);
!depositOutstanding && totalPaid < booking.total_amount
);
let partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount)); let partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
let partialAmountValid = $derived( let partialAmountValid = $derived(
paymentType === 'partial' && paymentType === 'partial' &&
partialAmount !== '' && partialAmount !== '' &&
!isNaN(partialAmountNum) && !isNaN(partialAmountNum) &&
partialAmountNum > 0 && partialAmountNum > 0 &&
partialAmountNum <= amountRemaining && partialAmountNum <= amountRemaining &&
/^\d+(\.\d{0,2})?$/.test(partialAmount) /^\d+(\.\d{0,2})?$/.test(partialAmount)
); );
let partialValidationError = $derived( let partialValidationError = $derived(
paymentType === 'partial' && !partialAmountValid ? ( paymentType === 'partial' && !partialAmountValid
partialAmount === '' ? partialAmount === ''
? 'Enter an amount' ? 'Enter an amount'
: !/^\d+(\.\d{0,2})?$/.test(partialAmount) : !/^\d+(\.\d{0,2})?$/.test(partialAmount)
? 'Invalid amount format' ? 'Invalid amount format'
@@ -182,7 +177,7 @@
: partialAmountNum > amountRemaining : partialAmountNum > amountRemaining
? 'Amount exceeds balance' ? 'Amount exceeds balance'
: 'Invalid amount' : 'Invalid amount'
) : null : null
); );
let payButtonDisabled = $derived( let payButtonDisabled = $derived(
@@ -190,9 +185,13 @@
); );
let payButtonError = $derived( let payButtonError = $derived(
status === 'processing' ? null : ( status === 'processing'
!cardSelected ? cardValidationError : (paymentType === 'partial' ? partialValidationError : null) ? null
) : !cardSelected
? cardValidationError
: paymentType === 'partial'
? partialValidationError
: null
); );
function formatCurrency(pence: number): string { function formatCurrency(pence: number): string {
@@ -388,7 +387,7 @@
</script> </script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}> <Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Content class="max-w-md max-h-[90vh] overflow-y-auto"> <Dialog.Content class="max-h-[90vh] max-w-md overflow-y-auto">
<Dialog.Header> <Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title> <Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
{#if booking.id} {#if booking.id}
@@ -406,7 +405,11 @@
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span> <span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
<span class="font-medium"> <span class="font-medium">
{service.override_price ? formatCurrency(Math.round(service.override_price * 100)) : service.price ? formatCurrency(Math.round(service.price * 100)) : '-'} {service.override_price
? formatCurrency(Math.round(service.override_price * 100))
: service.price
? formatCurrency(Math.round(service.price * 100))
: '-'}
</span> </span>
</div> </div>
{/each} {/each}
@@ -417,7 +420,8 @@
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4"> <div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span> <span class="text-gray-600">Total</span>
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span> <span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
>
</div> </div>
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span> <span class="text-gray-600">Amount Paid</span>
@@ -441,9 +445,13 @@
{#if selectedPaymentMethod} {#if selectedPaymentMethod}
{#each paymentMethods as method (method.id)} {#each paymentMethods as method (method.id)}
{#if method.id === selectedPaymentMethod} {#if method.id === selectedPaymentMethod}
<div class="flex items-center justify-between rounded-lg border border-input bg-fuchsia-100 p-3"> <div
class="flex items-center justify-between rounded-lg border border-input bg-fuchsia-100 p-3"
>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"> <div
class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"
>
{method.brand} {method.brand}
</div> </div>
<div class="text-sm"> <div class="text-sm">
@@ -474,7 +482,7 @@
{#if canSaveCards} {#if canSaveCards}
<button <button
type="button" type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 hover:border-gray-300 transition-colors" class="flex w-full items-center justify-between rounded-lg border p-3 transition-colors hover:border-gray-300"
onclick={() => { onclick={() => {
showNewCardForm = true; showNewCardForm = true;
showCardList = false; showCardList = false;
@@ -482,7 +490,13 @@
}} }}
> >
<div class="text-sm font-medium text-gray-700">Enter card details</div> <div class="text-sm font-medium text-gray-700">Enter card details</div>
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
class="h-4 w-4 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 18l6-6-6-6" /> <path d="M9 18l6-6-6-6" />
</svg> </svg>
</button> </button>
@@ -490,7 +504,10 @@
{#each paymentMethods as method (method.id)} {#each paymentMethods as method (method.id)}
<button <button
type="button" type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 {selectedPaymentMethod === method.id ? 'border-input bg-fuchsia-100' : 'border-input hover:bg-fuchsia-50'}" class="flex w-full items-center justify-between rounded-lg border p-3 {selectedPaymentMethod ===
method.id
? 'border-input bg-fuchsia-100'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => { onclick={() => {
selectedPaymentMethod = method.id; selectedPaymentMethod = method.id;
showNewCardForm = false; showNewCardForm = false;
@@ -498,7 +515,9 @@
}} }}
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"> <div
class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"
>
{method.brand} {method.brand}
</div> </div>
<div class="text-sm"> <div class="text-sm">
@@ -522,7 +541,9 @@
<h4 class="mb-3 text-sm font-medium text-gray-700">Card Details</h4> <h4 class="mb-3 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label for="cardNumber" class="text-sm font-medium text-gray-700">Card Number</label> <label for="cardNumber" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input <Input
id="cardNumber" id="cardNumber"
type="text" type="text"
@@ -536,7 +557,9 @@
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label for="cardExpiry" class="text-sm font-medium text-gray-700">Expiry (MM/YY)</label> <label for="cardExpiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input <Input
id="cardExpiry" id="cardExpiry"
type="text" type="text"
@@ -582,14 +605,20 @@
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'deposit' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}" class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'deposit'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'deposit')} onclick={() => (paymentType = 'deposit')}
> >
Pay Deposit Pay Deposit
</button> </button>
<button <button
type="button" type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'full' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}" class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'full'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'full')} onclick={() => (paymentType = 'full')}
> >
Pay in Full Pay in Full
@@ -601,13 +630,17 @@
<p class="text-sm text-red-600">{payButtonError}</p> <p class="text-sm text-red-600">{payButtonError}</p>
{/if} {/if}
<Button <Button
onclick={() => paymentType === 'deposit' ? handlePayDeposit() : handlePayFull()} onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="w-full" class="w-full"
loading={status === 'processing'} loading={status === 'processing'}
disabled={payButtonDisabled} disabled={payButtonDisabled}
> >
{#if paymentType === 'deposit'} {#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(booking.deposit_amount ? Math.round(booking.deposit_amount * 100) : Math.round(booking.total_amount * 0.2 * 100))}) Pay Deposit ({formatCurrency(
booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100)
)})
{:else} {:else}
Pay {formatCurrency(Math.round(booking.amount_due * 100))} Pay {formatCurrency(Math.round(booking.amount_due * 100))}
{/if} {/if}
@@ -622,14 +655,20 @@
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'full' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}" class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'full'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'full')} onclick={() => (paymentType = 'full')}
> >
Pay in Full Pay in Full
</button> </button>
<button <button
type="button" type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'partial' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}" class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'partial'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'partial')} onclick={() => (paymentType = 'partial')}
> >
Pay Part Pay Part
@@ -643,7 +682,7 @@
Partial Payment Amount Partial Payment Amount
</label> </label>
<div class="relative mt-1"> <div class="relative mt-1">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span> <span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input <Input
id="partial-amount" id="partial-amount"
type="text" type="text"
@@ -664,13 +703,15 @@
<p class="text-sm text-red-600">{payButtonError}</p> <p class="text-sm text-red-600">{payButtonError}</p>
{/if} {/if}
<Button <Button
onclick={() => paymentType === 'partial' ? handlePayPartial() : handlePayFull()} onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="w-full" class="w-full"
loading={status === 'processing'} loading={status === 'processing'}
disabled={payButtonDisabled} disabled={payButtonDisabled}
> >
{#if paymentType === 'partial'} {#if paymentType === 'partial'}
Pay {partialAmountValid ? formatCurrency(Math.round(partialAmountNum * 100)) : 'Part'} Pay {partialAmountValid
? formatCurrency(Math.round(partialAmountNum * 100))
: 'Part'}
{:else} {:else}
Pay {formatCurrency(Math.round(booking.amount_due * 100))} Pay {formatCurrency(Math.round(booking.amount_due * 100))}
{/if} {/if}
@@ -685,19 +726,24 @@
{/if} {/if}
<!-- Close button --> <!-- Close button -->
<Button variant="ghost" onclick={handleClose} class="w-full" disabled={status === 'processing'}> <Button
variant="ghost"
onclick={handleClose}
class="w-full"
disabled={status === 'processing'}
>
Close Close
</Button> </Button>
</div> </div>
{:else if status === 'polling'} {:else if status === 'polling'}
<!-- Polling State --> <!-- Polling State -->
<div class="flex flex-col items-center justify-center py-8"> <div class="flex flex-col items-center justify-center py-8">
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div> <div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Processing payment...</p> <p class="text-lg font-medium text-gray-700">Processing payment...</p>
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p> <p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
<Button variant="ghost" onclick={handleClose} class="mt-6"> <Button variant="ghost" onclick={handleClose} class="mt-6">Cancel</Button>
Cancel
</Button>
</div> </div>
{:else if status === 'success' && paymentResult} {:else if status === 'success' && paymentResult}
<!-- Success State --> <!-- Success State -->
@@ -750,9 +796,7 @@
</div> </div>
</div> </div>
<Button onclick={handleClose} class="w-full"> <Button onclick={handleClose} class="w-full">Done</Button>
Done
</Button>
</div> </div>
{/if} {/if}
</Dialog.Content> </Dialog.Content>
@@ -89,8 +89,18 @@
if (closingTime) { if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number); const [ch, cm] = closingTime.split(':').map(Number);
const today = new SvelteDate(); const today = new SvelteDate();
const closing = new SvelteDate(today.getFullYear(), today.getMonth() + 1, today.getDate(), ch, cm, 0); const closing = new SvelteDate(
const minutesToClosing = Math.max(0, Math.floor((closing.getTime() - endTime.getTime()) / 60000)); today.getFullYear(),
today.getMonth() + 1,
today.getDate(),
ch,
cm,
0
);
const minutesToClosing = Math.max(
0,
Math.floor((closing.getTime() - endTime.getTime()) / 60000)
);
if (!nextAppointment) { if (!nextAppointment) {
// No next appointment — show time until closing // No next appointment — show time until closing
@@ -213,7 +223,7 @@
{#if activeAppointment} {#if activeAppointment}
{#if isInProgress} {#if isInProgress}
<div class="flex flex-row flex-wrap gap-2 sm:flex-col sm:items-end"> <div class="flex flex-row flex-wrap gap-2 sm:flex-col sm:items-end">
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap"> <Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
<span class="relative mr-2 flex h-2 w-2"> <span class="relative mr-2 flex h-2 w-2">
<span <span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
@@ -224,18 +234,19 @@
</Badge> </Badge>
{#if timeRemaining > 29} {#if timeRemaining > 29}
{#if freeTimeCapped && freeTimeAfter === 0} {#if freeTimeCapped && freeTimeAfter === 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap"> <Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
closing after closing after
</Badge> </Badge>
{:else if freeTimeAfter > 0} {:else if freeTimeAfter > 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap"> <Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
{freeTimeAfter} min free afterwards{#if freeTimeCapped} (closing after){/if} {freeTimeAfter} min free afterwards{#if freeTimeCapped}
(closing after){/if}
</Badge> </Badge>
{/if} {/if}
{/if} {/if}
</div> </div>
{:else} {:else}
<Badge class="bg-amber-100 px-3 py-1 text-sm text-amber-800 whitespace-nowrap"> <Badge class="bg-amber-100 px-3 py-1 text-sm whitespace-nowrap text-amber-800">
Starts in {timeRemaining} min Starts in {timeRemaining} min
</Badge> </Badge>
{/if} {/if}
@@ -246,7 +257,7 @@
{#if loading} {#if loading}
<Card.Content class="space-y-4"> <Card.Content class="space-y-4">
<div class="flex gap-4"> <div class="flex gap-4">
<Skeleton class="h-16 w-16 md:h-20 md:w-20 rounded-full" /> <Skeleton class="h-16 w-16 rounded-full md:h-20 md:w-20" />
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<Skeleton class="h-6 w-48" /> <Skeleton class="h-6 w-48" />
<Skeleton class="h-4 w-32" /> <Skeleton class="h-4 w-32" />
@@ -280,23 +291,29 @@
<Card.Content> <Card.Content>
<div class="grid gap-6 md:grid-cols-3"> <div class="grid gap-6 md:grid-cols-3">
<!-- Customer Info --> <!-- Customer Info -->
<div class="flex items-center gap-3 sm:gap-4 min-w-0"> <div class="flex min-w-0 items-center gap-3 sm:gap-4">
{#if activeAppointment.user?.profile_pic_url} {#if activeAppointment.user?.profile_pic_url}
<img <img
src={activeAppointment.user.profile_pic_url} src={activeAppointment.user.profile_pic_url}
alt={activeAppointment.user.full_name} alt={activeAppointment.user.full_name}
class="h-14 w-14 sm:h-16 sm:w-16 md:h-20 md:w-20 shrink-0 rounded-full object-cover ring-2 sm:ring-4 ring-blue-200" class="h-14 w-14 shrink-0 rounded-full object-cover ring-2 ring-blue-200 sm:h-16 sm:w-16 sm:ring-4 md:h-20 md:w-20"
/> />
{:else} {:else}
{@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'} {@const initials =
activeAppointment.user?.full_name
?.split(' ')
.map((n) => n[0])
.join('') || '?'}
<div <div
class="flex h-14 w-14 sm:h-16 sm:w-16 md:h-20 md:w-20 shrink-0 items-center justify-center rounded-full bg-gray-200 text-xl sm:text-2xl font-bold text-gray-600 ring-2 sm:ring-4 ring-blue-200" class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-gray-200 text-xl font-bold text-gray-600 ring-2 ring-blue-200 sm:h-16 sm:w-16 sm:text-2xl sm:ring-4 md:h-20 md:w-20"
> >
{initials} {initials}
</div> </div>
{/if} {/if}
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-lg font-semibold">{activeAppointment.user?.full_name || 'Guest'}</div> <div class="truncate text-lg font-semibold">
{activeAppointment.user?.full_name || 'Guest'}
</div>
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div> <div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
{#if activeAppointment.user} {#if activeAppointment.user}
<button <button
@@ -321,7 +338,11 @@
<div class="text-xs text-gray-600">{service.service_description}</div> <div class="text-xs text-gray-600">{service.service_description}</div>
{/if} {/if}
<div class="mt-1 flex items-center justify-between text-xs text-gray-500"> <div class="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{formatDuration(service.override_duration_minutes ?? service.duration_minutes ?? 0)}</span> <span
>{formatDuration(
service.override_duration_minutes ?? service.duration_minutes ?? 0
)}</span
>
</div> </div>
</div> </div>
{/each} {/each}
@@ -413,7 +434,11 @@
</svg> </svg>
Cancel Cancel
</Button> </Button>
<Button size="sm" onclick={handleTakePayment} class="col-span-2 bg-green-600 hover:bg-green-700"> <Button
size="sm"
onclick={handleTakePayment}
class="col-span-2 bg-green-600 hover:bg-green-700"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4" class="mr-2 h-4 w-4"
@@ -437,5 +462,9 @@
</Card.Root> </Card.Root>
{#if showPaymentModal} {#if showPaymentModal}
<PaymentModal booking={activeAppointment} onClose={() => showPaymentModal = false} onComplete={handlePaymentComplete} /> <PaymentModal
booking={activeAppointment}
onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete}
/>
{/if} {/if}
@@ -93,7 +93,9 @@
let selectedEditRequest = $state<EditRequest | null>(null); let selectedEditRequest = $state<EditRequest | null>(null);
let _hasItems = $derived(pendingApprovals.length > 0 || pendingEditRequests.length > 0); let _hasItems = $derived(pendingApprovals.length > 0 || pendingEditRequests.length > 0);
$effect(() => { hasItems = _hasItems; }); $effect(() => {
hasItems = _hasItems;
});
// Helper function to format date nicely // Helper function to format date nicely
function formatDateTime(dateTimeString: string): string { function formatDateTime(dateTimeString: string): string {
@@ -396,7 +398,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium">{er.user?.full_name || 'Unknown'}</span> <span class="font-medium">{er.user?.full_name || 'Unknown'}</span>
<span class="text-xs text-amber-600 font-medium">Edit Request</span> <span class="text-xs font-medium text-amber-600">Edit Request</span>
</div> </div>
<div class="mt-1 text-sm text-gray-600"> <div class="mt-1 text-sm text-gray-600">
{getEditRequestSummary(er)} {getEditRequestSummary(er)}
@@ -10,7 +10,11 @@
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/dialog'; import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { calculateMiddleWindow, extractBookedSlots, findAllLunchGaps } from '$lib/lunchProtection'; import {
calculateMiddleWindow,
extractBookedSlots,
findAllLunchGaps
} from '$lib/lunchProtection';
import { formatDuration } from '$lib/utils/format'; import { formatDuration } from '$lib/utils/format';
interface Props { interface Props {
@@ -125,8 +129,6 @@
return { hour, minute, period }; return { hour, minute, period };
} }
function isAutoGenerated(blocker: TimeBlocker): boolean { function isAutoGenerated(blocker: TimeBlocker): boolean {
return blocker.description?.startsWith('RESERVATION:') ?? false; return blocker.description?.startsWith('RESERVATION:') ?? false;
} }
@@ -136,7 +138,13 @@
if (!wh || !wh.isOpen) return []; if (!wh || !wh.isOpen) return [];
const startMin = timeToMinutes(wh.startTime); const startMin = timeToMinutes(wh.startTime);
const endMin = timeToMinutes(wh.endTime); const endMin = timeToMinutes(wh.endTime);
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = []; const options: Array<{
hour: string;
minute: string;
period: 'AM' | 'PM';
totalMin: number;
label: string;
}> = [];
for (let m = startMin; m < endMin; m += 15) { for (let m = startMin; m < endMin; m += 15) {
const t = minutesTo12h(m); const t = minutesTo12h(m);
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` }); options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
@@ -149,8 +157,18 @@
if (!wh || !wh.isOpen) return []; if (!wh || !wh.isOpen) return [];
const endMin = timeToMinutes(wh.endTime); const endMin = timeToMinutes(wh.endTime);
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod)); const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = []; const options: Array<{
for (let m = Math.max(timeToMinutes(wh.startTime), currentStartMin + 15); m <= endMin; m += 15) { hour: string;
minute: string;
period: 'AM' | 'PM';
totalMin: number;
label: string;
}> = [];
for (
let m = Math.max(timeToMinutes(wh.startTime), currentStartMin + 15);
m <= endMin;
m += 15
) {
const t = minutesTo12h(m); const t = minutesTo12h(m);
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` }); options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
} }
@@ -195,7 +213,13 @@
const largestGap = gapInfos[0]; const largestGap = gapInfos[0];
const startTimeStr = `${minutesTo12h(largestGap.start).hour}:${minutesTo12h(largestGap.start).minute} ${minutesTo12h(largestGap.start).period}`; const startTimeStr = `${minutesTo12h(largestGap.start).hour}:${minutesTo12h(largestGap.start).minute} ${minutesTo12h(largestGap.start).period}`;
const endTimeStr = `${minutesTo12h(largestGap.end).hour}:${minutesTo12h(largestGap.end).minute} ${minutesTo12h(largestGap.end).period}`; const endTimeStr = `${minutesTo12h(largestGap.end).hour}:${minutesTo12h(largestGap.end).minute} ${minutesTo12h(largestGap.end).period}`;
return { startMin: largestGap.start, endMin: largestGap.end, duration: largestGap.duration, startLabel: startTimeStr, endLabel: endTimeStr }; return {
startMin: largestGap.start,
endMin: largestGap.end,
duration: largestGap.duration,
startLabel: startTimeStr,
endLabel: endTimeStr
};
}); });
type AppointmentTimelineItem = { type AppointmentTimelineItem = {
@@ -257,7 +281,11 @@
startMinutes: suggestedLunch.startMin, startMinutes: suggestedLunch.startMin,
endMinutes: suggestedLunch.endMin, endMinutes: suggestedLunch.endMin,
type: 'lunch', type: 'lunch',
data: { duration: suggestedLunch.duration, startLabel: suggestedLunch.startLabel, endLabel: suggestedLunch.endLabel } data: {
duration: suggestedLunch.duration,
startLabel: suggestedLunch.startLabel,
endLabel: suggestedLunch.endLabel
}
}); });
} }
@@ -308,15 +336,33 @@
try { try {
const dateParam = today; const dateParam = today;
const [blockersRes, whRes, ahRes] = await Promise.all([ const [blockersRes, whRes, ahRes] = await Promise.all([
fetch(`/api/admin/time-blockers?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, { fetch(
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } `/api/admin/time-blockers?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
}), {
fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, { headers: {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } 'Content-Type': 'application/json',
}), Authorization: `Bearer ${authStore.currentToken}`
fetch(`/api/scheduling/available-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, { }
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } }
}) ),
fetch(
`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
),
fetch(
`/api/scheduling/available-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
)
]); ]);
if (blockersRes.ok) { if (blockersRes.ok) {
@@ -357,33 +403,58 @@
async function checkOverlappingBookings() { async function checkOverlappingBookings() {
const startIso = buildDateTime(today, startHour, startMinute, startPeriod); const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
const endIso = buildDateTime(today, endHour, endMinute, endPeriod); const endIso = buildDateTime(today, endHour, endMinute, endPeriod);
if (!startIso || !endIso) { overlappingBookings = []; hasOverlap = false; return; } if (!startIso || !endIso) {
overlappingBookings = [];
hasOverlap = false;
return;
}
const blockerStart = new SvelteDate(startIso); const blockerStart = new SvelteDate(startIso);
const blockerEnd = new SvelteDate(endIso); const blockerEnd = new SvelteDate(endIso);
if (blockerEnd.getTime() <= blockerStart.getTime()) { overlappingBookings = []; hasOverlap = false; return; } if (blockerEnd.getTime() <= blockerStart.getTime()) {
overlappingBookings = [];
hasOverlap = false;
return;
}
checkingOverlap = true; checkingOverlap = true;
try { try {
const response = await fetch(`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`, { const response = await fetch(
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } `/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`,
}); {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const allBookings: OverlappingBooking[] = data.bookings || []; const allBookings: OverlappingBooking[] = data.bookings || [];
const filtered = allBookings.filter((b) => { const filtered = allBookings.filter((b) => {
const bStart = new SvelteDate(b.start_time); const bStart = new SvelteDate(b.start_time);
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000); const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
return bStart < blockerEnd && bEnd > blockerStart; return bStart < blockerEnd && bEnd > blockerStart;
}); });
overlappingBookings = filtered; overlappingBookings = filtered;
hasOverlap = overlappingBookings.length > 0; hasOverlap = overlappingBookings.length > 0;
} else { overlappingBookings = []; hasOverlap = false; } } else {
overlappingBookings = [];
hasOverlap = false;
}
} catch (err) { } catch (err) {
console.error('Error checking overlapping bookings:', err); console.error('Error checking overlapping bookings:', err);
overlappingBookings = []; hasOverlap = false; overlappingBookings = [];
} finally { checkingOverlap = false; } hasOverlap = false;
} finally {
checkingOverlap = false;
}
} }
function buildDateTime(date: string, hour: string, minute: string, period: 'AM' | 'PM'): string | null { function buildDateTime(
date: string,
hour: string,
minute: string,
period: 'AM' | 'PM'
): string | null {
if (!date) return null; if (!date) return null;
const t24 = to24h(hour, minute, period); const t24 = to24h(hour, minute, period);
return `${date}T${t24}:00`; return `${date}T${t24}:00`;
@@ -404,8 +475,15 @@
try { try {
const response = await fetch('/api/admin/time-blockers', { const response = await fetch('/api/admin/time-blockers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }, headers: {
body: JSON.stringify({ start_time: start.toISOString(), duration_minutes: durationMinutes, description: newDescription.trim() || 'break' }) 'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
start_time: start.toISOString(),
duration_minutes: durationMinutes,
description: newDescription.trim() || 'break'
})
}); });
if (response.ok) { if (response.ok) {
toast.success('Time blocker created!', { id: loadingToast }); toast.success('Time blocker created!', { id: loadingToast });
@@ -419,7 +497,9 @@
} catch (err) { } catch (err) {
console.error('Error creating time blocker:', err); console.error('Error creating time blocker:', err);
toast.error('Network error creating time blocker', { id: loadingToast }); toast.error('Network error creating time blocker', { id: loadingToast });
} finally { creating = false; } } finally {
creating = false;
}
} }
async function confirmDeleteBlocker() { async function confirmDeleteBlocker() {
@@ -447,9 +527,14 @@
function resetCreateForm() { function resetCreateForm() {
newDescription = ''; newDescription = '';
startHour = '9'; startMinute = '00'; startPeriod = 'AM'; startHour = '9';
endHour = '10'; endMinute = '00'; endPeriod = 'AM'; startMinute = '00';
overlappingBookings = []; hasOverlap = false; startPeriod = 'AM';
endHour = '10';
endMinute = '00';
endPeriod = 'AM';
overlappingBookings = [];
hasOverlap = false;
} }
function openCreateModal() { function openCreateModal() {
@@ -457,8 +542,12 @@
if (workingHours && workingHours.isOpen) { if (workingHours && workingHours.isOpen) {
const startOpt = minutesTo12h(timeToMinutes(workingHours.startTime)); const startOpt = minutesTo12h(timeToMinutes(workingHours.startTime));
const endOpt = minutesTo12h(timeToMinutes(workingHours.startTime) + 60); const endOpt = minutesTo12h(timeToMinutes(workingHours.startTime) + 60);
startHour = startOpt.hour; startMinute = startOpt.minute; startPeriod = startOpt.period; startHour = startOpt.hour;
endHour = endOpt.hour; endMinute = endOpt.minute; endPeriod = endOpt.period; startMinute = startOpt.minute;
startPeriod = startOpt.period;
endHour = endOpt.hour;
endMinute = endOpt.minute;
endPeriod = endOpt.period;
} }
showCreateModal = true; showCreateModal = true;
} }
@@ -551,7 +640,14 @@
<Card.Description>Timeline view of all bookings</Card.Description> <Card.Description>Timeline view of all bookings</Card.Description>
</div> </div>
<Button variant="outline" size="sm" onclick={openCreateModal}> <Button variant="outline" size="sm" onclick={openCreateModal}>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -563,15 +659,15 @@
{#if loading} {#if loading}
<div class="space-y-3"> <div class="space-y-3">
{#each Array(5) as _, i (i)} {#each Array(5) as _, i (i)}
<div class="flex items-center gap-3 sm:gap-4 rounded-lg border p-3"> <div class="flex items-center gap-3 rounded-lg border p-3 sm:gap-4">
<Skeleton class="h-4 w-[45px] sm:w-[60px] lg:w-[80px]" /> <Skeleton class="h-4 w-[45px] sm:w-[60px] lg:w-[80px]" />
<Skeleton class="h-10 w-1 shrink-0" /> <Skeleton class="h-10 w-1 shrink-0" />
<div class="flex-1 space-y-2 min-w-0"> <div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-4 w-32" /> <Skeleton class="h-4 w-32" />
<Skeleton class="h-3 w-48" /> <Skeleton class="h-3 w-48" />
</div> </div>
<Skeleton class="h-6 w-20 hidden sm:block shrink-0" /> <Skeleton class="hidden h-6 w-20 shrink-0 sm:block" />
<Skeleton class="h-8 w-9 sm:w-16 shrink-0" /> <Skeleton class="h-8 w-9 shrink-0 sm:w-16" />
</div> </div>
{/each} {/each}
</div> </div>
@@ -599,38 +695,51 @@
{#each timeline as item (item.id)} {#each timeline as item (item.id)}
{#if item.type === 'appointment'} {#if item.type === 'appointment'}
<div <div
class="flex items-center gap-3 sm:gap-4 rounded-lg border p-3 transition-all hover:shadow-md class="flex items-center gap-3 rounded-lg border p-3 transition-all hover:shadow-md sm:gap-4
{isPastAppointment(item.data.start_time, item.data.duration_minutes) ? 'line-through opacity-50' : ''}" {isPastAppointment(item.data.start_time, item.data.duration_minutes)
? 'line-through opacity-50'
: ''}"
> >
<div class="min-w-[45px] sm:min-w-[60px] lg:min-w-[80px] text-xs sm:text-sm font-semibold text-gray-700 shrink-0"> <div
class="min-w-[45px] shrink-0 text-xs font-semibold text-gray-700 sm:min-w-[60px] sm:text-sm lg:min-w-[80px]"
>
{formatTime(item.data.start_time)} {formatTime(item.data.start_time)}
</div> </div>
<div class="h-10 w-1 shrink-0 rounded {getStatusBarColor(item.data.status)}"></div> <div class="h-10 w-1 shrink-0 rounded {getStatusBarColor(item.data.status)}"></div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<button <button
type="button" type="button"
class="truncate font-medium hover:text-blue-600 hover:underline max-w-full block" class="block max-w-full truncate font-medium hover:text-blue-600 hover:underline"
onclick={() => openUserModal(item.data.user_id)} onclick={() => openUserModal(item.data.user_id)}
> >
{item.data.user_name} {item.data.user_name}
</button> </button>
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600"> <div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
<span class="truncate">{item.data.services.join(', ')}</span> <span class="truncate">{item.data.services.join(', ')}</span>
<span class="shrink-0 rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium"> <span
class="shrink-0 rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium"
>
{formatDuration(item.data.duration_minutes)} {formatDuration(item.data.duration_minutes)}
</span> </span>
</div> </div>
</div> </div>
<Badge class="{getStatusColor(item.data.status)} hidden sm:inline-flex shrink-0"> <Badge class="{getStatusColor(item.data.status)} hidden shrink-0 sm:inline-flex">
{formatStatus(item.data.status)} {formatStatus(item.data.status)}
</Badge> </Badge>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
class="shrink-0 sm:px-3 h-9 w-9 sm:w-auto" class="h-9 w-9 shrink-0 sm:w-auto sm:px-3"
onclick={() => openBookingModal(item.data.id)} onclick={() => openBookingModal(item.data.id)}
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 sm:mr-1.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 sm:mr-1.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
@@ -638,39 +747,77 @@
</Button> </Button>
</div> </div>
{:else if item.type === 'blocker'} {:else if item.type === 'blocker'}
<div class="flex items-center gap-4 rounded-lg border border-amber-200 bg-amber-50/40 p-3 transition-all hover:shadow-sm"> <div
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-gray-700"> class="flex items-center gap-4 rounded-lg border border-amber-200 bg-amber-50/40 p-3 transition-all hover:shadow-sm"
>
<div class="min-w-[60px] text-sm font-semibold text-gray-700 sm:min-w-[80px]">
{formatTime(item.data.start_time)} {formatTime(item.data.start_time)}
</div> </div>
<div class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-amber-100"> <div
<svg class="h-4 w-4 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-amber-100"
>
<svg
class="h-4 w-4 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
</div> </div>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-1"> <div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-1">
<div class="truncate text-sm font-medium text-gray-900">{item.data.description || 'Untitled'}</div> <div class="truncate text-sm font-medium text-gray-900">
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600"> {item.data.description || 'Untitled'}
</div>
<span
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600"
>
{formatDuration(item.data.duration_minutes)} {formatDuration(item.data.duration_minutes)}
</span> </span>
</div> </div>
<Button <Button
variant="destructive" size="sm" class="shrink-0 h-9 w-9 p-0" variant="destructive"
onclick={() => { blockerToDelete = item.data; showDeleteAlert = true; }} size="sm"
class="h-9 w-9 shrink-0 p-0"
onclick={() => {
blockerToDelete = item.data;
showDeleteAlert = true;
}}
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6" /> <polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
{:else if item.type === 'lunch'} {:else if item.type === 'lunch'}
<div class="flex items-center gap-4 rounded-lg border border-green-200 bg-green-50 p-3"> <div
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-green-700"> class="flex items-center gap-4 rounded-lg border border-green-200 bg-green-50 p-3"
>
<div class="min-w-[60px] text-sm font-semibold text-green-700 sm:min-w-[80px]">
{item.data.startLabel} {item.data.startLabel}
</div> </div>
<div class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-green-100"> <div
<svg class="h-4 w-4 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-green-100"
>
<svg
class="h-4 w-4 text-green-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 8h1a4 4 0 0 1 0 8h-1" /> <path d="M18 8h1a4 4 0 0 1 0 8h-1" />
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" /> <path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" />
<line x1="6" y1="1" x2="6" y2="4" /> <line x1="6" y1="1" x2="6" y2="4" />
@@ -705,7 +852,12 @@
<div class="space-y-5 px-4 pb-4"> <div class="space-y-5 px-4 pb-4">
<div class="space-y-2"> <div class="space-y-2">
<label for="blocker-desc" class="text-sm font-medium">Description</label> <label for="blocker-desc" class="text-sm font-medium">Description</label>
<Input id="blocker-desc" type="text" placeholder="e.g., Lunch, Appointment, Break" bind:value={newDescription} /> <Input
id="blocker-desc"
type="text"
placeholder="e.g., Lunch, Appointment, Break"
bind:value={newDescription}
/>
<p class="text-xs text-gray-400">Defaults to "break" if left empty</p> <p class="text-xs text-gray-400">Defaults to "break" if left empty</p>
</div> </div>
<Separator /> <Separator />
@@ -718,7 +870,17 @@
{:else if !workingHours.isOpen} {:else if !workingHours.isOpen}
<p class="text-sm text-red-500">Closed today</p> <p class="text-sm text-red-500">Closed today</p>
{:else} {:else}
<select class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" bind:value={startSelectValue} onchange={() => { const p = parseSelectValue(startSelectValue); startHour = p.hour; startMinute = p.minute; startPeriod = p.period; checkOverlappingBookings(); }}> <select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
bind:value={startSelectValue}
onchange={() => {
const p = parseSelectValue(startSelectValue);
startHour = p.hour;
startMinute = p.minute;
startPeriod = p.period;
checkOverlappingBookings();
}}
>
{#each availableStartOptions as opt} {#each availableStartOptions as opt}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option> <option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each} {/each}
@@ -736,7 +898,17 @@
{:else if availableEndOptions.length === 0} {:else if availableEndOptions.length === 0}
<p class="text-sm text-gray-400">No available end time after selected start</p> <p class="text-sm text-gray-400">No available end time after selected start</p>
{:else} {:else}
<select class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" bind:value={endSelectValue} onchange={() => { const p = parseSelectValue(endSelectValue); endHour = p.hour; endMinute = p.minute; endPeriod = p.period; checkOverlappingBookings(); }}> <select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
bind:value={endSelectValue}
onchange={() => {
const p = parseSelectValue(endSelectValue);
endHour = p.hour;
endMinute = p.minute;
endPeriod = p.period;
checkOverlappingBookings();
}}
>
{#each availableEndOptions as opt} {#each availableEndOptions as opt}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option> <option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each} {/each}
@@ -746,16 +918,36 @@
{#if checkingOverlap} {#if checkingOverlap}
<div class="flex items-center gap-2 text-sm text-gray-500"> <div class="flex items-center gap-2 text-sm text-gray-500">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"> <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> <circle
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg> </svg>
Checking for conflicting bookings… Checking for conflicting bookings…
</div> </div>
{:else if hasOverlap} {:else if hasOverlap}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3"> <div class="rounded-lg border border-amber-200 bg-amber-50 p-3">
<div class="flex items-center gap-2 mb-2"> <div class="mb-2 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /> xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
@@ -765,17 +957,32 @@
{#each overlappingBookings as booking (booking.id)} {#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-2"> <div class="rounded-md border border-amber-200 bg-white p-2">
<div class="min-w-0"> <div class="min-w-0">
<div class="font-medium text-sm">{booking.user?.full_name || 'Unknown'}</div> <div class="text-sm font-medium">{booking.user?.full_name || 'Unknown'}</div>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit', hour12: true })} {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
{' · '}{formatDuration(booking.duration_minutes)} {' · '}{formatDuration(booking.duration_minutes)}
{#if booking.services?.length} {' · '}{booking.services.join(', ')}{/if} {#if booking.services?.length}
{' · '}{booking.services.join(', ')}{/if}
</div> </div>
</div> </div>
<div class="flex gap-2 mt-1.5"> <div class="mt-1.5 flex gap-2">
<Button variant="outline" size="sm" class="text-xs h-6" onclick={() => openBookingModal(booking.id)}>View Booking</Button> <Button
variant="outline"
size="sm"
class="h-6 text-xs"
onclick={() => openBookingModal(booking.id)}>View Booking</Button
>
{#if booking.user?.id} {#if booking.user?.id}
<Button variant="outline" size="sm" class="text-xs h-6" onclick={() => openUserModal(booking.user!.id)}>View Client</Button> <Button
variant="outline"
size="sm"
class="h-6 text-xs"
onclick={() => openUserModal(booking.user!.id)}>View Client</Button
>
{/if} {/if}
</div> </div>
</div> </div>
@@ -785,7 +992,14 @@
{/if} {/if}
</div> </div>
<Modal.Footer class="flex items-center justify-end gap-2"> <Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => { showCreateModal = false; resetCreateForm(); }} disabled={creating}>Cancel</Button> <Button
variant="outline"
onclick={() => {
showCreateModal = false;
resetCreateForm();
}}
disabled={creating}>Cancel</Button
>
<Button onclick={createBlocker} disabled={!canCreate || creating || checkingOverlap}> <Button onclick={createBlocker} disabled={!canCreate || creating || checkingOverlap}>
{creating ? 'Creating…' : 'Create Blocker'} {creating ? 'Creating…' : 'Create Blocker'}
</Button> </Button>
@@ -797,11 +1011,21 @@
<AlertDialog.Content class="z-[60]"> <AlertDialog.Content class="z-[60]">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title> <AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
<AlertDialog.Description>This will remove the "{blockerToDelete?.description}" time blocker. This action cannot be undone.</AlertDialog.Description> <AlertDialog.Description
>This will remove the "{blockerToDelete?.description}" time blocker. This action cannot be
undone.</AlertDialog.Description
>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => { showDeleteAlert = false; blockerToDelete = null; }}>Cancel</AlertDialog.Cancel> <AlertDialog.Cancel
<AlertDialog.Action onclick={confirmDeleteBlocker} class="bg-red-600 hover:bg-red-700">Delete</AlertDialog.Action> onclick={() => {
showDeleteAlert = false;
blockerToDelete = null;
}}>Cancel</AlertDialog.Cancel
>
<AlertDialog.Action onclick={confirmDeleteBlocker} class="bg-red-600 hover:bg-red-700"
>Delete</AlertDialog.Action
>
</AlertDialog.Footer> </AlertDialog.Footer>
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
@@ -57,9 +57,15 @@
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const res = await fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateStr)}&end=${encodeURIComponent(dateStr)}`, { const res = await fetch(
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } `/api/scheduling/working-hours?start=${encodeURIComponent(dateStr)}&end=${encodeURIComponent(dateStr)}`,
}); {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (res.ok) { if (res.ok) {
const data: DayWorkingHours[] = await res.json(); const data: DayWorkingHours[] = await res.json();
@@ -88,11 +94,20 @@
const dateParam = today; const dateParam = today;
const [apptsRes, whRes] = await Promise.all([ const [apptsRes, whRes] = await Promise.all([
fetch(`/api/admin/today/appointments`, { fetch(`/api/admin/today/appointments`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}), }),
fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, { fetch(
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } `/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
}) {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
)
]); ]);
let appointments: TodayAppointment[] = []; let appointments: TodayAppointment[] = [];
@@ -111,34 +126,60 @@
const total = appointments.length; const total = appointments.length;
const completed = appointments.filter((a) => a.status === 'completed').length; const completed = appointments.filter((a) => a.status === 'completed').length;
const inProgress = appointments.filter((a) => a.status === 'in_progress').length; const inProgress = appointments.filter((a) => a.status === 'in_progress').length;
const upcoming = appointments.filter((a) => a.status === 'confirmed' || a.status === 'pending').length; const upcoming = appointments.filter(
const cancelled = appointments.filter((a) => a.status === 'client_cancelled' || a.status === 'we_cancelled').length; (a) => a.status === 'confirmed' || a.status === 'pending'
).length;
const cancelled = appointments.filter(
(a) => a.status === 'client_cancelled' || a.status === 'we_cancelled'
).length;
const noShow = appointments.filter((a) => a.status === 'no_show').length; const noShow = appointments.filter((a) => a.status === 'no_show').length;
let occupancyPercent = 0; let occupancyPercent = 0;
if (workingHours?.isOpen) { if (workingHours?.isOpen) {
const workingMinutes = timeToMinutes(workingHours.endTime) - timeToMinutes(workingHours.startTime); const workingMinutes =
timeToMinutes(workingHours.endTime) - timeToMinutes(workingHours.startTime);
const bookedMinutes = appointments const bookedMinutes = appointments
.filter((a) => a.status !== 'client_cancelled' && a.status !== 'we_cancelled' && a.status !== 'no_show') .filter(
(a) =>
a.status !== 'client_cancelled' &&
a.status !== 'we_cancelled' &&
a.status !== 'no_show'
)
.reduce((sum, a) => sum + a.duration_minutes, 0); .reduce((sum, a) => sum + a.duration_minutes, 0);
occupancyPercent = workingMinutes > 0 ? Math.round((bookedMinutes / workingMinutes) * 100) : 0; occupancyPercent =
workingMinutes > 0 ? Math.round((bookedMinutes / workingMinutes) * 100) : 0;
} }
const { cutoff, spansClosed } = await findLastWorkingDayClose(); const { cutoff, spansClosed } = await findLastWorkingDayClose();
const nowISO = new SvelteDate().toISOString(); const nowISO = new SvelteDate().toISOString();
let bookingsMade = 0; let bookingsMade = 0;
try { try {
const bmRes = await fetch(`/api/admin/bookings/by-created-range?start=${encodeURIComponent(cutoff)}&end=${encodeURIComponent(nowISO)}`, { const bmRes = await fetch(
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } `/api/admin/bookings/by-created-range?start=${encodeURIComponent(cutoff)}&end=${encodeURIComponent(nowISO)}`,
}); {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (bmRes.ok) { if (bmRes.ok) {
const data = await bmRes.json(); const data = await bmRes.json();
bookingsMade = (data.bookings || []).length; bookingsMade = (data.bookings || []).length;
} }
} catch { } catch {}
}
const newStats: Stats = { total, completed, inProgress, upcoming, cancelled, noShow, occupancyPercent, bookingsMade, bookingsMadeOverClosed: spansClosed }; const newStats: Stats = {
total,
completed,
inProgress,
upcoming,
cancelled,
noShow,
occupancyPercent,
bookingsMade,
bookingsMadeOverClosed: spansClosed
};
const newJson = JSON.stringify(newStats); const newJson = JSON.stringify(newStats);
if (newJson !== prevJson) { if (newJson !== prevJson) {
stats = newStats; stats = newStats;
@@ -223,7 +264,9 @@
{#if stats.bookingsMade > 0} {#if stats.bookingsMade > 0}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600"> <span class="text-sm text-gray-600">
Bookings Made{#if stats.bookingsMadeOverClosed}<sup class="text-xs text-gray-400">*</sup>{/if} Bookings Made{#if stats.bookingsMadeOverClosed}<sup class="text-xs text-gray-400"
>*</sup
>{/if}
</span> </span>
<span class="font-semibold">{stats.bookingsMade}</span> <span class="font-semibold">{stats.bookingsMade}</span>
</div> </div>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from "$lib/components/ui/button/index.js"; import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from "$lib/components/ui/button/index.js"; import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,6 +13,6 @@
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
bind:ref bind:ref
data-slot="alert-dialog-cancel" data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)} class={cn(buttonVariants({ variant: 'outline' }), className)}
{...restProps} {...restProps}
/> />
@@ -19,7 +19,7 @@
bind:ref bind:ref
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
class={cn( 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 mm:max-w-lg 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 data-[state=closed]:duration-[130ms]', 'mm:max-w-lg 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 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,6 +12,6 @@
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
bind:ref bind:ref
data-slot="alert-dialog-description" data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)} class={cn('text-sm text-muted-foreground', className)}
{...restProps} {...restProps}
/> />
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="alert-dialog-header" data-slot="alert-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} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref bind:ref
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
class={cn( 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 duration-200 data-[state=closed]:duration-[130ms]", 'fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,6 +12,6 @@
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
bind:ref bind:ref
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)} class={cn('text-lg font-semibold', className)}
{...restProps} {...restProps}
/> />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props(); let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script> </script>
@@ -1,13 +1,13 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import Trigger from "./alert-dialog-trigger.svelte"; import Trigger from './alert-dialog-trigger.svelte';
import Title from "./alert-dialog-title.svelte"; import Title from './alert-dialog-title.svelte';
import Action from "./alert-dialog-action.svelte"; import Action from './alert-dialog-action.svelte';
import Cancel from "./alert-dialog-cancel.svelte"; import Cancel from './alert-dialog-cancel.svelte';
import Footer from "./alert-dialog-footer.svelte"; import Footer from './alert-dialog-footer.svelte';
import Header from "./alert-dialog-header.svelte"; import Header from './alert-dialog-header.svelte';
import Overlay from "./alert-dialog-overlay.svelte"; import Overlay from './alert-dialog-overlay.svelte';
import Content from "./alert-dialog-content.svelte"; import Content from './alert-dialog-content.svelte';
import Description from "./alert-dialog-description.svelte"; import Description from './alert-dialog-description.svelte';
const Root = AlertDialogPrimitive.Root; const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal; const Portal = AlertDialogPrimitive.Portal;
@@ -35,5 +35,5 @@ export {
Trigger as AlertDialogTrigger, Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay, Overlay as AlertDialogOverlay,
Content as AlertDialogContent, Content as AlertDialogContent,
Description as AlertDialogDescription, Description as AlertDialogDescription
}; };
@@ -12,6 +12,6 @@
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
bind:ref bind:ref
data-slot="avatar-fallback" data-slot="avatar-fallback"
class={cn('bg-muted flex size-full items-center justify-center rounded-full', className)} class={cn('flex size-full items-center justify-center rounded-full bg-muted', className)}
{...restProps} {...restProps}
/> />
@@ -1,36 +1,35 @@
<script lang="ts" module> <script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from 'tailwind-variants';
export const badgeVariants = tv({ export const badgeVariants = 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 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3", 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 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: { variants: {
variant: { variant: {
default: default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary: secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent", 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
destructive: destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white", 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default'
}, }
}); });
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"]; export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
</script> </script>
<script lang="ts"> <script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements"; import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
href, href,
class: className, class: className,
variant = "default", variant = 'default',
children, children,
...restProps ...restProps
}: WithElementRef<HTMLAnchorAttributes> & { }: WithElementRef<HTMLAnchorAttributes> & {
@@ -39,7 +38,7 @@
</script> </script>
<svelte:element <svelte:element
this={href ? "a" : "span"} this={href ? 'a' : 'span'}
bind:this={ref} bind:this={ref}
data-slot="badge" data-slot="badge"
{href} {href}
@@ -1,2 +1,2 @@
export { default as Badge } from "./badge.svelte"; export { default as Badge } from './badge.svelte';
export { badgeVariants, type BadgeVariant } from "./badge.svelte"; export { badgeVariants, type BadgeVariant } from './badge.svelte';
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { ComponentProps } from "svelte"; import type { ComponentProps } from 'svelte';
import type Calendar from "./calendar.svelte"; import type Calendar from './calendar.svelte';
import CalendarMonthSelect from "./calendar-month-select.svelte"; import CalendarMonthSelect from './calendar-month-select.svelte';
import CalendarYearSelect from "./calendar-year-select.svelte"; import CalendarYearSelect from './calendar-year-select.svelte';
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date"; import { DateFormatter, getLocalTimeZone, type DateValue } from '@internationalized/date';
let { let {
captionLayout, captionLayout,
@@ -14,13 +14,13 @@
month, month,
locale, locale,
placeholder = $bindable(), placeholder = $bindable(),
monthIndex = 0, monthIndex = 0
}: { }: {
captionLayout: ComponentProps<typeof Calendar>["captionLayout"]; captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
months: ComponentProps<typeof CalendarMonthSelect>["months"]; months: ComponentProps<typeof CalendarMonthSelect>['months'];
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"]; monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
years: ComponentProps<typeof CalendarYearSelect>["years"]; years: ComponentProps<typeof CalendarYearSelect>['years'];
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"]; yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
month: DateValue; month: DateValue;
placeholder: DateValue | undefined; placeholder: DateValue | undefined;
locale: string; locale: string;
@@ -29,13 +29,13 @@
function formatYear(date: DateValue) { function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone()); const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear()); if (typeof yearFormat === 'function') return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj); return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
} }
function formatMonth(date: DateValue) { function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone()); const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1); if (typeof monthFormat === 'function') return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj); return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
} }
</script> </script>
@@ -58,15 +58,15 @@
<CalendarYearSelect {years} {yearFormat} value={month.year} /> <CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet} {/snippet}
{#if captionLayout === "dropdown"} {#if captionLayout === 'dropdown'}
{@render MonthSelect()} {@render MonthSelect()}
{@render YearSelect()} {@render YearSelect()}
{:else if captionLayout === "dropdown-months"} {:else if captionLayout === 'dropdown-months'}
{@render MonthSelect()} {@render MonthSelect()}
{#if placeholder} {#if placeholder}
{formatYear(placeholder)} {formatYear(placeholder)}
{/if} {/if}
{:else if captionLayout === "dropdown-years"} {:else if captionLayout === 'dropdown-years'}
{#if placeholder} {#if placeholder}
{formatMonth(placeholder)} {formatMonth(placeholder)}
{/if} {/if}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,7 +12,7 @@
<CalendarPrimitive.Cell <CalendarPrimitive.Cell
bind:ref bind:ref
class={cn( 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", 'relative size-(--cell-size) 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 className
)} )}
{...restProps} {...restProps}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/index.js"; import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,24 +13,24 @@
<CalendarPrimitive.Day <CalendarPrimitive.Day
bind:ref bind:ref
class={cn( class={cn(
buttonVariants({ variant: "ghost" }), 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", 'flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none',
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground", '[&[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", 'data-[selected]:bg-primary data-[selected]:text-primary-foreground dark:data-[selected]:hover:bg-accent/50',
// Outside months // Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground", '[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground',
// Disabled // Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable // Unavailable
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through", 'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through',
// Today dot (::after pseudo-element on [data-today]) // Today dot (::after pseudo-element on [data-today])
"[&[data-today]]:after:block [&[data-today]]:after:content-[''] [&[data-today]]:after:w-1 [&[data-today]]:after:h-1 [&[data-today]]:after:bg-current [&[data-today]]:after:rounded-full [&[data-today]]:after:mx-auto [&[data-today]]:after:mt-[-2px] [&[data-today]]:after:opacity-70", "[&[data-today]]:after:mx-auto [&[data-today]]:after:mt-[-2px] [&[data-today]]:after:block [&[data-today]]:after:h-1 [&[data-today]]:after:w-1 [&[data-today]]:after:rounded-full [&[data-today]]:after:bg-current [&[data-today]]:after:opacity-70 [&[data-today]]:after:content-['']",
// hover // hover
"dark:hover:text-accent-foreground", 'dark:hover:text-accent-foreground',
// focus // focus
"focus:border-ring focus:ring-ring/50 focus:relative", 'focus:relative focus:border-ring focus:ring-ring/50',
// inner spans // inner spans
"[&>span]:text-xs [&>span]:opacity-70", '[&>span]:text-xs [&>span]:opacity-70',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -9,4 +9,4 @@
}: CalendarPrimitive.GridRowProps = $props(); }: CalendarPrimitive.GridRowProps = $props();
</script> </script>
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} /> <CalendarPrimitive.GridRow bind:ref class={cn('flex', className)} {...restProps} />
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -11,6 +11,6 @@
<CalendarPrimitive.Grid <CalendarPrimitive.Grid
bind:ref bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)} class={cn('mt-4 flex w-full border-collapse flex-col gap-1', className)}
{...restProps} {...restProps}
/> />
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,7 +12,7 @@
<CalendarPrimitive.HeadCell <CalendarPrimitive.HeadCell
bind:ref bind:ref
class={cn( class={cn(
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal", 'w-(--cell-size) rounded-md text-[0.8rem] font-normal text-muted-foreground',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,7 +12,7 @@
<CalendarPrimitive.Header <CalendarPrimitive.Header
bind:ref bind:ref
class={cn( class={cn(
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium", 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -11,6 +11,6 @@
<CalendarPrimitive.Heading <CalendarPrimitive.Heading
bind:ref bind:ref
class={cn("px-(--cell-size) text-sm font-medium", className)} class={cn('px-(--cell-size) text-sm font-medium', className)}
{...restProps} {...restProps}
/> />
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
<span <span
class={cn( class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border", 'relative flex rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
className className
)} )}
> >
@@ -33,7 +33,7 @@
{/each} {/each}
</select> </select>
<span <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" class="flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground"
aria-hidden="true" aria-hidden="true"
> >
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label} {monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { type WithElementRef, cn } from "$lib/utils.js"; import { type WithElementRef, cn } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -10,6 +10,6 @@
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script> </script>
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}> <div {...restProps} bind:this={ref} class={cn('flex flex-col', className)}>
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,7 +12,7 @@
<div <div
bind:this={ref} bind:this={ref}
class={cn("relative flex flex-col gap-4 md:flex-row", className)} class={cn('relative flex flex-col gap-4 md:flex-row', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<nav <nav
{...restProps} {...restProps}
bind:this={ref} bind:this={ref}
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)} class={cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', className)}
> >
{@render children?.()} {@render children?.()}
</nav> </nav>
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right"; import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js"; import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
variant = "ghost", variant = 'ghost',
...restProps ...restProps
}: CalendarPrimitive.NextButtonProps & { }: CalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant; variant?: ButtonVariant;
@@ -23,7 +23,7 @@
bind:ref bind:ref
class={cn( class={cn(
buttonVariants({ variant }), buttonVariants({ variant }),
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180", 'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
className className
)} )}
children={children || Fallback} children={children || Fallback}
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left"; import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js"; import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
variant = "ghost", variant = 'ghost',
...restProps ...restProps
}: CalendarPrimitive.PrevButtonProps & { }: CalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant; variant?: ButtonVariant;
@@ -23,7 +23,7 @@
bind:ref bind:ref
class={cn( class={cn(
buttonVariants({ variant }), buttonVariants({ variant }),
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180", 'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
className className
)} )}
children={children || Fallback} children={children || Fallback}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<span <span
class={cn( class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border", 'relative flex rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
className className
)} )}
> >
@@ -32,7 +32,7 @@
{/each} {/each}
</select> </select>
<span <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" class="flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground"
aria-hidden="true" aria-hidden="true"
> >
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label} {yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
@@ -52,7 +52,7 @@ get along, so we shut typescript up by casting `value` to `never`.
class={cn( class={cn(
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent', '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 // Add fuchsia theme styles for selected date
'[&_[data-selected]]:border-primary [&_[data-selected]]:text-primary [&_[data-selected]]:rounded-md [&_[data-selected]]:bg-fuchsia-100 [&_[data-selected]]:shadow-sm', '[&_[data-selected]]:rounded-md [&_[data-selected]]:border-primary [&_[data-selected]]:bg-fuchsia-100 [&_[data-selected]]:text-primary [&_[data-selected]]:shadow-sm',
'[&_[data-selected]:hover]:bg-fuchsia-50', '[&_[data-selected]:hover]:bg-fuchsia-50',
// Ensure today indicator works with fuchsia theme // Ensure today indicator works with fuchsia theme
'[&_[data-today]:not([data-selected])]:bg-accent [&_[data-today]:not([data-selected])]:text-accent-foreground', '[&_[data-today]:not([data-selected])]:bg-accent [&_[data-today]:not([data-selected])]:text-accent-foreground',
@@ -1,21 +1,21 @@
import Root from "./calendar.svelte"; import Root from './calendar.svelte';
import Cell from "./calendar-cell.svelte"; import Cell from './calendar-cell.svelte';
import Day from "./calendar-day.svelte"; import Day from './calendar-day.svelte';
import Grid from "./calendar-grid.svelte"; import Grid from './calendar-grid.svelte';
import Header from "./calendar-header.svelte"; import Header from './calendar-header.svelte';
import Months from "./calendar-months.svelte"; import Months from './calendar-months.svelte';
import GridRow from "./calendar-grid-row.svelte"; import GridRow from './calendar-grid-row.svelte';
import Heading from "./calendar-heading.svelte"; import Heading from './calendar-heading.svelte';
import GridBody from "./calendar-grid-body.svelte"; import GridBody from './calendar-grid-body.svelte';
import GridHead from "./calendar-grid-head.svelte"; import GridHead from './calendar-grid-head.svelte';
import HeadCell from "./calendar-head-cell.svelte"; import HeadCell from './calendar-head-cell.svelte';
import NextButton from "./calendar-next-button.svelte"; import NextButton from './calendar-next-button.svelte';
import PrevButton from "./calendar-prev-button.svelte"; import PrevButton from './calendar-prev-button.svelte';
import MonthSelect from "./calendar-month-select.svelte"; import MonthSelect from './calendar-month-select.svelte';
import YearSelect from "./calendar-year-select.svelte"; import YearSelect from './calendar-year-select.svelte';
import Month from "./calendar-month.svelte"; import Month from './calendar-month.svelte';
import Nav from "./calendar-nav.svelte"; import Nav from './calendar-nav.svelte';
import Caption from "./calendar-caption.svelte"; import Caption from './calendar-caption.svelte';
export { export {
Day, Day,
@@ -36,5 +36,5 @@ export {
MonthSelect, MonthSelect,
Caption, Caption,
// //
Root as Calendar, Root as Calendar
}; };
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-action" data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -10,6 +10,6 @@
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}> <div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}>
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<p <p
bind:this={ref} bind:this={ref}
data-slot="card-description" data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)} class={cn('text-sm text-muted-foreground', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-footer" data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)} class={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="card-header" data-slot="card-header"
class={cn( 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", '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-title" data-slot="card-title"
class={cn("font-semibold leading-none", className)} class={cn('leading-none font-semibold', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
class={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6', className)} class={cn('flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
+8 -8
View File
@@ -1,10 +1,10 @@
import Root from "./card.svelte"; import Root from './card.svelte';
import Content from "./card-content.svelte"; import Content from './card-content.svelte';
import Description from "./card-description.svelte"; import Description from './card-description.svelte';
import Footer from "./card-footer.svelte"; import Footer from './card-footer.svelte';
import Header from "./card-header.svelte"; import Header from './card-header.svelte';
import Title from "./card-title.svelte"; import Title from './card-title.svelte';
import Action from "./card-action.svelte"; import Action from './card-action.svelte';
export { export {
Root, Root,
@@ -21,5 +21,5 @@ export {
Footer as CardFooter, Footer as CardFooter,
Header as CardHeader, Header as CardHeader,
Title as CardTitle, Title as CardTitle,
Action as CardAction, Action as CardAction
}; };
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui"; import { Checkbox as CheckboxPrimitive } from 'bits-ui';
import CheckIcon from "@lucide/svelte/icons/check"; import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from "@lucide/svelte/icons/minus"; import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -17,7 +17,7 @@
bind:ref bind:ref
data-slot="checkbox" data-slot="checkbox"
class={cn( 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", 'peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
className className
)} )}
bind:checked bind:checked
@@ -1,6 +1,6 @@
import Root from "./checkbox.svelte"; import Root from './checkbox.svelte';
export { export {
Root, Root,
// //
Root as Checkbox, Root as Checkbox
}; };
@@ -25,7 +25,7 @@
bind:ref bind:ref
data-slot="dialog-content" data-slot="dialog-content"
class={cn( 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 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 data-[state=closed]:duration-[130ms] sm:max-w-lg', '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 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className className
)} )}
{...restProps} {...restProps}
@@ -33,7 +33,7 @@
{@render children?.()} {@render children?.()}
{#if !hideClose} {#if !hideClose}
<DialogPrimitive.Close <DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute top-4 right-4 flex h-8 w-8 items-center justify-center 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" class="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
> >
<XIcon /> <XIcon />
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
@@ -12,6 +12,6 @@
<DialogPrimitive.Description <DialogPrimitive.Description
bind:ref bind:ref
data-slot="dialog-description" data-slot="dialog-description"
class={cn('text-muted-foreground text-sm', className)} class={cn('text-sm text-muted-foreground', className)}
{...restProps} {...restProps}
/> />
@@ -13,7 +13,7 @@
bind:ref bind:ref
data-slot="dialog-overlay" data-slot="dialog-overlay"
class={cn( 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 duration-200 data-[state=closed]:duration-[130ms]', 'fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className className
)} )}
{...restProps} {...restProps}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import * as Button from "$lib/components/ui/button/index.js"; import * as Button from '$lib/components/ui/button/index.js';
let { ref = $bindable(null), ...restProps }: Button.Props = $props(); let { ref = $bindable(null), ...restProps }: Button.Props = $props();
</script> </script>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as FormPrimitive from "formsnap"; import * as FormPrimitive from 'formsnap';
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,6 +12,6 @@
<FormPrimitive.Description <FormPrimitive.Description
bind:ref bind:ref
data-slot="form-description" data-slot="form-description"
class={cn("text-muted-foreground text-sm", className)} class={cn('text-sm text-muted-foreground', className)}
{...restProps} {...restProps}
/> />
@@ -1,8 +1,8 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>"> <script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
import * as FormPrimitive from "formsnap"; import * as FormPrimitive from 'formsnap';
import type { FormPathLeaves } from "sveltekit-superforms"; import type { FormPathLeaves } from 'sveltekit-superforms';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js"; import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -17,7 +17,7 @@
<FormPrimitive.ElementField {form} {name}> <FormPrimitive.ElementField {form} {name}>
{#snippet children({ constraints, errors, tainted, value })} {#snippet children({ constraints, errors, tainted, value })}
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}> <div bind:this={ref} class={cn('space-y-2', className)} {...restProps}>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div> </div>
{/snippet} {/snippet}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as FormPrimitive from "formsnap"; import * as FormPrimitive from 'formsnap';
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -15,7 +15,7 @@
<FormPrimitive.FieldErrors <FormPrimitive.FieldErrors
bind:ref bind:ref
class={cn("text-destructive text-sm font-medium", className)} class={cn('text-sm font-medium text-destructive', className)}
{...restProps} {...restProps}
> >
{#snippet children({ errors, errorProps })} {#snippet children({ errors, errorProps })}
@@ -1,8 +1,8 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>"> <script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap"; import * as FormPrimitive from 'formsnap';
import type { FormPath } from "sveltekit-superforms"; import type { FormPath } from 'sveltekit-superforms';
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js"; import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -17,12 +17,7 @@
<FormPrimitive.Field {form} {name}> <FormPrimitive.Field {form} {name}>
{#snippet children({ constraints, errors, tainted, value })} {#snippet children({ constraints, errors, tainted, value })}
<div <div bind:this={ref} data-slot="form-item" class={cn('space-y-2', className)} {...restProps}>
bind:this={ref}
data-slot="form-item"
class={cn("space-y-2", className)}
{...restProps}
>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div> </div>
{/snippet} {/snippet}
@@ -1,7 +1,7 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>"> <script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap"; import * as FormPrimitive from 'formsnap';
import type { FormPath } from "sveltekit-superforms"; import type { FormPath } from 'sveltekit-superforms';
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,4 +12,4 @@
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props(); }: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
</script> </script>
<FormPrimitive.Fieldset bind:ref {form} {name} class={cn("space-y-2", className)} {...restProps} /> <FormPrimitive.Fieldset bind:ref {form} {name} class={cn('space-y-2', className)} {...restProps} />
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as FormPrimitive from "formsnap"; import * as FormPrimitive from 'formsnap';
import { Label } from "$lib/components/ui/label/index.js"; import { Label } from '$lib/components/ui/label/index.js';
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -16,7 +16,7 @@
<Label <Label
{...props} {...props}
data-slot="form-label" data-slot="form-label"
class={cn("data-[fs-error]:text-destructive", className)} class={cn('data-[fs-error]:text-destructive', className)}
> >
{@render children?.()} {@render children?.()}
</Label> </Label>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as FormPrimitive from "formsnap"; import * as FormPrimitive from 'formsnap';
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -11,6 +11,6 @@
<FormPrimitive.Legend <FormPrimitive.Legend
bind:ref bind:ref
class={cn("data-[fs-error]:text-destructive text-sm font-medium leading-none", className)} class={cn('text-sm leading-none font-medium data-[fs-error]:text-destructive', className)}
{...restProps} {...restProps}
/> />
+10 -10
View File
@@ -1,12 +1,12 @@
import * as FormPrimitive from "formsnap"; import * as FormPrimitive from 'formsnap';
import Description from "./form-description.svelte"; import Description from './form-description.svelte';
import Label from "./form-label.svelte"; import Label from './form-label.svelte';
import FieldErrors from "./form-field-errors.svelte"; import FieldErrors from './form-field-errors.svelte';
import Field from "./form-field.svelte"; import Field from './form-field.svelte';
import Fieldset from "./form-fieldset.svelte"; import Fieldset from './form-fieldset.svelte';
import Legend from "./form-legend.svelte"; import Legend from './form-legend.svelte';
import ElementField from "./form-element-field.svelte"; import ElementField from './form-element-field.svelte';
import Button from "./form-button.svelte"; import Button from './form-button.svelte';
const Control = FormPrimitive.Control; const Control = FormPrimitive.Control;
@@ -29,5 +29,5 @@ export {
Fieldset as FormFieldset, Fieldset as FormFieldset,
Legend as FormLegend, Legend as FormLegend,
ElementField as FormElementField, ElementField as FormElementField,
Button as FormButton, Button as FormButton
}; };
@@ -14,7 +14,7 @@
{@render child({ src: previewState.rootState.src })} {@render child({ src: previewState.rootState.src })}
{:else} {:else}
<Avatar.Root <Avatar.Root
class={cn('ring-accent ring-offset-background size-20 ring-2 ring-offset-2', className)} class={cn('size-20 ring-2 ring-accent ring-offset-2 ring-offset-background', className)}
> >
<Avatar.Image src={previewState.rootState.src} /> <Avatar.Image src={previewState.rootState.src} />
<Avatar.Fallback> <Avatar.Fallback>
@@ -1,7 +1,7 @@
import Root from "./input.svelte"; import Root from './input.svelte';
export { export {
Root, Root,
// //
Root as Input, Root as Input
}; };
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements"; import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
type InputType = Exclude<HTMLInputTypeAttribute, "file">; type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
type Props = WithElementRef< type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> & Omit<HTMLInputAttributes, 'type'> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined }) ({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
>; >;
let { let {
@@ -15,19 +15,19 @@
type, type,
files = $bindable(), files = $bindable(),
class: className, class: className,
"data-slot": dataSlot = "input", 'data-slot': dataSlot = 'input',
...restProps ...restProps
}: Props = $props(); }: Props = $props();
</script> </script>
{#if type === "file"} {#if type === 'file'}
<input <input
bind:this={ref} bind:this={ref}
data-slot={dataSlot} data-slot={dataSlot}
class={cn( 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", 'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30',
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className className
)} )}
type="file" type="file"
@@ -40,9 +40,9 @@
bind:this={ref} bind:this={ref}
data-slot={dataSlot} data-slot={dataSlot}
class={cn( 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", 'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className className
)} )}
{type} {type}
@@ -1,7 +1,7 @@
import Root from "./label.svelte"; import Root from './label.svelte';
export { export {
Root, Root,
// //
Root as Label, Root as Label
}; };
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Label as LabelPrimitive } from "bits-ui"; import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref bind:ref
data-slot="label" data-slot="label"
class={cn( 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", 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className className
)} )}
{...restProps} {...restProps}
+11 -11
View File
@@ -1,14 +1,14 @@
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from 'bits-ui';
import Group from "./select-group.svelte"; import Group from './select-group.svelte';
import Label from "./select-label.svelte"; import Label from './select-label.svelte';
import Item from "./select-item.svelte"; import Item from './select-item.svelte';
import Content from "./select-content.svelte"; import Content from './select-content.svelte';
import Trigger from "./select-trigger.svelte"; import Trigger from './select-trigger.svelte';
import Separator from "./select-separator.svelte"; import Separator from './select-separator.svelte';
import ScrollDownButton from "./select-scroll-down-button.svelte"; import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from "./select-scroll-up-button.svelte"; import ScrollUpButton from './select-scroll-up-button.svelte';
import GroupHeading from "./select-group-heading.svelte"; import GroupHeading from './select-group-heading.svelte';
const Root = SelectPrimitive.Root; const Root = SelectPrimitive.Root;
@@ -33,5 +33,5 @@ export {
Separator as SelectSeparator, Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton, ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton, ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading, GroupHeading as SelectGroupHeading
}; };
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from 'bits-ui';
import SelectScrollUpButton from "./select-scroll-up-button.svelte"; import SelectScrollUpButton from './select-scroll-up-button.svelte';
import SelectScrollDownButton from "./select-scroll-down-button.svelte"; import SelectScrollDownButton from './select-scroll-down-button.svelte';
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -22,7 +22,7 @@
{sideOffset} {sideOffset}
data-slot="select-content" data-slot="select-content"
class={cn( 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", 'relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className className
)} )}
{...restProps} {...restProps}
@@ -30,7 +30,7 @@
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
class={cn( class={cn(
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1" 'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
)} )}
> >
{@render children?.()} {@render children?.()}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
import type { ComponentProps } from "svelte"; import type { ComponentProps } from 'svelte';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
<SelectPrimitive.GroupHeading <SelectPrimitive.GroupHeading
bind:ref bind:ref
data-slot="select-group-heading" data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props(); let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script> </script>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check"; import CheckIcon from '@lucide/svelte/icons/check';
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -18,7 +18,7 @@
{value} {value}
data-slot="select-item" data-slot="select-item"
class={cn( 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", "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="select-label" data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
bind:ref bind:ref
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)} class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps} {...restProps}
> >
<ChevronDownIcon class="size-4" /> <ChevronDownIcon class="size-4" />
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up"; import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
bind:ref bind:ref
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)} class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps} {...restProps}
> >
<ChevronUpIcon class="size-4" /> <ChevronUpIcon class="size-4" />
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui"; import type { Separator as SeparatorPrimitive } from 'bits-ui';
import { Separator } from "$lib/components/ui/separator/index.js"; import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,6 +13,6 @@
<Separator <Separator
bind:ref bind:ref
data-slot="select-separator" data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} class={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...restProps} {...restProps}
/> />
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from 'bits-ui';
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
size = "default", size = 'default',
...restProps ...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & { }: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default"; size?: 'sm' | 'default';
} = $props(); } = $props();
</script> </script>
@@ -19,7 +19,7 @@
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
class={cn( 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", "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground 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 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className className
)} )}
{...restProps} {...restProps}
@@ -1,7 +1,7 @@
import Root from "./separator.svelte"; import Root from './separator.svelte';
export { export {
Root, Root,
// //
Root as Separator, Root as Separator
}; };
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui"; import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
"data-slot": dataSlot = "separator", 'data-slot': dataSlot = 'separator',
...restProps ...restProps
}: SeparatorPrimitive.RootProps = $props(); }: SeparatorPrimitive.RootProps = $props();
</script> </script>
@@ -14,7 +14,7 @@
bind:ref bind:ref
data-slot={dataSlot} data-slot={dataSlot}
class={cn( 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", 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className className
)} )}
{...restProps} {...restProps}

Some files were not shown because too many files have changed in this diff Show More