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
@@ -9,7 +9,11 @@
import * as Label from '$lib/components/ui/label';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
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 RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -94,32 +98,31 @@
);
let canSubmit = $derived(
submitting === false && (
(editMode === 'time' && !!newDate && newTime.length >= 4) ||
(editMode === 'services' && selectedServices.length > 0) ||
(editMode === 'both-time' && !!newDate && newTime.length >= 4)
)
submitting === false &&
((editMode === 'time' && !!newDate && newTime.length >= 4) ||
(editMode === 'services' && selectedServices.length > 0) ||
(editMode === 'both-time' && !!newDate && newTime.length >= 4))
);
let notesChanged = $derived(notes.trim() !== originalNotes.trim());
let modalTitle = $derived(() => {
switch (editMode) {
case 'select': return 'Edit Request';
case 'time': return 'Change Time';
case 'services': return 'Change Services';
case 'both-services': return 'Change Services';
case 'both-time': return 'Choose Time';
case 'select':
return 'Edit Request';
case 'time':
return 'Change Time';
case 'services':
return 'Change Services';
case 'both-services':
return 'Change Services';
case 'both-time':
return 'Choose Time';
}
});
const lunchProtection = $derived(() => {
if (
!newDate ||
!workingHours ||
!availableHours ||
slotDuration === 0
) {
if (!newDate || !workingHours || !availableHours || slotDuration === 0) {
return new Map();
}
@@ -131,11 +134,7 @@
return new Map();
}
const existingBookings = extractBookedSlots(
dayWH.startTime,
dayWH.endTime,
dayAH.slots
);
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
return getLunchProtectionForSlots(
dayWH.startTime,
@@ -316,7 +315,11 @@
const dayAH = availableHours?.[dateStr];
if (dayAH?.slots) {
const existingBookings = extractBookedSlots(dayHours.startTime, dayHours.endTime, dayAH.slots);
const existingBookings = extractBookedSlots(
dayHours.startTime,
dayHours.endTime,
dayAH.slots
);
const lunchProtection = getLunchProtectionForSlots(
dayHours.startTime,
dayHours.endTime,
@@ -390,7 +393,14 @@
// ─── Auto-select ────────────────────────────────────────
$effect(() => {
if (!workingHours || !availableHours || newDate || userNavigatedCalendar || editRequestAutoSelectDone) return;
if (
!workingHours ||
!availableHours ||
newDate ||
userNavigatedCalendar ||
editRequestAutoSelectDone
)
return;
editRequestAutoSelectDone = true;
const currentDate = new SvelteDate();
@@ -419,11 +429,7 @@
if (!isDateUnavailable(calDate)) {
newDate = calDate;
if (!userNavigatedCalendar) {
placeholderDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1
);
placeholderDate = new CalendarDate(nextDate.getFullYear(), nextDate.getMonth() + 1, 1);
}
return;
}
@@ -432,11 +438,7 @@
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
newDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
newDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, tomorrow.getDate());
if (!userNavigatedCalendar) {
placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
}
@@ -716,7 +718,9 @@
</script>
<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>
<div class="flex items-center justify-between">
<Modal.Title class="text-lg font-semibold">{modalTitle()}</Modal.Title>
@@ -734,7 +738,9 @@
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"
>
<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" />
</div>
<div class="flex-1">
@@ -749,9 +755,13 @@
onclick={() => selectMode('services')}
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"
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" />
</div>
<div class="flex-1">
@@ -766,11 +776,15 @@
onclick={() => selectMode('both-services')}
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"
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" />
<RefreshCwIcon class="size-5 -ml-2" />
<RefreshCwIcon class="-ml-2 size-5" />
</div>
<div class="flex-1">
<div class="font-medium">Change Both</div>
@@ -781,11 +795,11 @@
{#if hasOverrides}
<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>
{/if}
</div>
{:else if editMode === 'time' || editMode === 'both-time'}
<!-- ─── Time Selection ────────────────────────── -->
{#if loadingHours && !workingHours}
@@ -799,7 +813,7 @@
placeholder={placeholderDate}
minValue={minDate}
maxValue={maxCalendarDate}
isDateUnavailable={isDateUnavailable}
{isDateUnavailable}
onchange={(d) => {
newDate = d;
newTime = '';
@@ -819,15 +833,15 @@
<p class="text-sm text-gray-500">Loading times...</p>
</div>
{: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">
{newDate
.toDate(getLocalTimeZone())
.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'short'
})}
{newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'short'
})}
</div>
{#if workingHours && !workingHours[newDate.toString()]?.isOpen}
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
@@ -844,7 +858,9 @@
<Button
variant="outline"
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)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
@@ -878,7 +894,6 @@
rows={2}
/>
</div>
{:else if editMode === 'services'}
<!-- ─── Services Only (with time restriction) ── -->
{#if hasOverrides}
@@ -897,9 +912,7 @@
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Current Services
{#if selectedServices.length > 0}
<span class="ml-1 text-xs font-normal text-gray-400">
(tap to remove)
</span>
<span class="ml-1 text-xs font-normal text-gray-400"> (tap to remove) </span>
{/if}
</h4>
{#if selectedServices.length === 0}
@@ -912,16 +925,18 @@
onclick={() => {
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>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p>
</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 class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
<span class="font-semibold text-foreground"
>£{service.price.toFixed(2)}</span
>
</div>
</div>
</button>
@@ -942,24 +957,26 @@
</h4>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each fittingServices as service (service.id)}
<button
type="button"
onclick={() => {
selectedServices = [...selectedServices, service];
}}
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>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p>
<button
type="button"
onclick={() => {
selectedServices = [...selectedServices, service];
}}
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
>
<div class="flex h-full min-h-[6rem] flex-col justify-between">
<div>
<h3 class="font-semibold">{service.name}</h3>
<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 class="flex items-center justify-between mt-2 text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
</div>
</div>
</button>
</button>
{/each}
</div>
</div>
@@ -969,15 +986,15 @@
</div>
{/if}
{: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.
</div>
{/if}
{#if selectedServices.length === 0}
<p class="text-xs text-amber-600">
Select at least one service to continue.
</p>
<p class="text-xs text-amber-600">Select at least one service to continue.</p>
{/if}
</div>
{/if}
@@ -991,7 +1008,8 @@
</div>
{#if originalNotes}
<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>
{/if}
<Textarea.Root
@@ -1001,7 +1019,6 @@
rows={2}
/>
</div>
{:else if editMode === 'both-services'}
<!-- ─── Both Step 1: Service Selection (no time restriction) ── -->
{#if hasOverrides}
@@ -1022,9 +1039,7 @@
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Selected Services
{#if selectedServices.length > 0}
<span class="ml-1 text-xs font-normal text-gray-400">
(tap to remove)
</span>
<span class="ml-1 text-xs font-normal text-gray-400"> (tap to remove) </span>
{/if}
</h4>
{#if selectedServices.length === 0}
@@ -1037,16 +1052,18 @@
onclick={() => {
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>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p>
</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 class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
<span class="font-semibold text-foreground"
>£{service.price.toFixed(2)}</span
>
</div>
</div>
</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"
>
<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>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p>
</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 class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
<span class="font-semibold text-foreground"
>£{service.price.toFixed(2)}</span
>
</div>
</div>
</button>
@@ -1086,9 +1105,7 @@
{/if}
{#if selectedServices.length === 0}
<p class="text-xs text-amber-600">
Select at least one service to continue.
</p>
<p class="text-xs text-amber-600">Select at least one service to continue.</p>
{/if}
</div>
@@ -1101,7 +1118,8 @@
</div>
{#if originalNotes}
<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>
{/if}
<Textarea.Root
@@ -1122,13 +1140,8 @@
<Button variant="ghost" size="sm" class="flex-1" onclick={() => (open = false)}>
Cancel
</Button>
{:else if editMode === 'time' || editMode === 'services'}
<Button
variant="outline"
size="sm"
onclick={goBack}
>
<Button variant="outline" size="sm" onclick={goBack}>
<ArrowLeftIcon class="size-4" />
Back
</Button>
@@ -1142,13 +1155,8 @@
>
{submitting ? 'Submitting...' : 'Submit Request'}
</Button>
{:else if editMode === 'both-services'}
<Button
variant="outline"
size="sm"
onclick={goBack}
>
<Button variant="outline" size="sm" onclick={goBack}>
<ArrowLeftIcon class="size-4" />
Back
</Button>
@@ -1165,13 +1173,8 @@
Next: Choose Time
<ArrowRightIcon class="size-4" />
</Button>
{:else if editMode === 'both-time'}
<Button
variant="outline"
size="sm"
onclick={goBack}
>
<Button variant="outline" size="sm" onclick={goBack}>
<ArrowLeftIcon class="size-4" />
Back
</Button>
@@ -45,9 +45,7 @@
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
);
let canEditBooking = $derived(
isCancellable && !hasPayments
);
let canEditBooking = $derived(isCancellable && !hasPayments);
let totalPaid = $derived(
selectedBooking?.payments
@@ -249,7 +247,9 @@
</script>
<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>
<div class="flex items-center justify-between">
<div>
@@ -500,7 +500,6 @@
</div>
</div>
{/if}
</div>
{/if}
@@ -516,14 +515,16 @@
Cancel Booking
</Button>
{#if canEditBooking}
<Button
variant="outline"
size="sm"
class="flex-1"
onclick={() => { showEditModal = true; }}
>
Edit Request
</Button>
<Button
variant="outline"
size="sm"
class="flex-1"
onclick={() => {
showEditModal = true;
}}
>
Edit Request
</Button>
{/if}
{/if}
</div>
@@ -66,16 +66,20 @@
function getBookingDateTime(): string {
if (!booking?.start_time) return '';
const d = new SvelteDate(booking.start_time);
return d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
}) + ' at ' + d.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
return (
d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
}) +
' at ' +
d.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})
);
}
function getTotalCost(): number {
@@ -447,36 +451,36 @@
</div>
{/if}
<!-- Customer Contact Info -->
<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">
Customer Contact
</h3>
<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">
<!-- Customer Contact Info -->
<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">
Customer Contact
</h3>
<div class="space-y-2">
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{booking.user?.phone || '—'}</div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.full_name || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
<div class="grid gap-3 md:grid-cols-2">
<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>
<!-- Booking Date & Time -->
<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">
Booking Date & Time
</h3>
<div class="text-lg font-medium">{getBookingDateTime()}</div>
</div>
<!-- Booking Date & Time -->
<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">
Booking Date & Time
</h3>
<div class="text-lg font-medium">{getBookingDateTime()}</div>
</div>
<!-- Booking Notes -->
<div>
@@ -74,7 +74,11 @@
// Step 4: Date & Time
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 selectedTime = $state<string | null>(null);
@@ -129,7 +133,7 @@
// =============== Lunch Protection ===============
const lunchProtection = $derived(
(selectedDate && selectedServices.length > 0)
selectedDate && selectedServices.length > 0
? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true)
: new Map()
);
@@ -197,7 +201,16 @@
// Auto-select first available date once data loads (timing-safe, data-driven)
let bookingCreateAutoSelectDone = $state(false);
$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;
const now = new SvelteDate();
const maxDateJs = new SvelteDate(
@@ -213,7 +226,11 @@
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
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)) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
@@ -342,7 +359,9 @@
const whMap: 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 }));
if (gen !== hoursRangeGeneration) return; // Stale response, discard
@@ -435,7 +454,7 @@
} finally {
loadingWorkingHours = 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(
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;
// Build lunch protection specifically for the date being checked
const dayProtection = buildLunchProtection(date as CalendarDate, workingHours, availableHours, duration, true);
const slots = generateAvailableTimeSlots(date as CalendarDate, workingHours, availableHours, duration, dayProtection);
const dayProtection = buildLunchProtection(
date as CalendarDate,
workingHours,
availableHours,
duration,
true
);
const slots = generateAvailableTimeSlots(
date as CalendarDate,
workingHours,
availableHours,
duration,
dayProtection
);
if (slots.length === 0) return true;
return false;
@@ -620,32 +657,32 @@
try {
let finalUserId = selectedUserId;
if (userType === 'guest') {
const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Guest',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Customer',
phone: phone,
email: `callin-${Date.now()}@guest.invalid`
})
});
if (userType === 'guest') {
const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Guest',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Customer',
phone: phone,
email: `callin-${Date.now()}@guest.invalid`
})
});
if (!createRes.ok) {
toast.error('Failed to create guest user');
submitting = false;
return;
if (!createRes.ok) {
toast.error('Failed to create guest user');
submitting = false;
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 (!selectedDate || !selectedTime) throw new Error('Date and time required');
@@ -734,7 +771,9 @@
</script>
<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.Title>Create Admin Booking</Modal.Title>
<Modal.Description>
@@ -1096,17 +1135,30 @@
<Card.Content class="space-y-4 p-0">
<!-- Reservation Countdown Banner -->
{#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 gap-2">
<svg 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
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>
<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>
</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)
</span>
</div>
@@ -1123,11 +1175,11 @@
selectedDate = newDate;
selectedTime = null;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
userNavigatedCalendar = true;
fetchHoursForMonth(newPlaceholder);
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
userNavigatedCalendar = true;
fetchHoursForMonth(newPlaceholder);
}}
/>
</div>
@@ -1139,14 +1191,18 @@
<div class="border-t">
<div class="max-h-64 overflow-y-auto p-6">
{#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}
<TimeSlotList
slots={groupedTimeSlots}
{selectedTime}
duration={getTotalDuration()}
protection={lunchProtection}
onSelect={(time) => { selectedTime = time; }}
onSelect={(time) => {
selectedTime = time;
}}
/>
</div>
</div>
@@ -1177,7 +1233,11 @@
onclick={submitBooking}
class="bg-primary text-primary-foreground"
>
{isReserving ? 'Reserving Slot...' : submitting ? 'Creating Booking...' : 'Create Booking'}
{isReserving
? 'Reserving Slot...'
: submitting
? 'Creating Booking...'
: 'Create Booking'}
</Button>
</Card.Footer>
</Card.Root>
@@ -25,8 +25,10 @@
// customised at booking time (discounts, extended sessions). Showing base values
// misleads admins about what was actually booked.
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
);
async function fetchBookingDetails() {
@@ -135,7 +137,9 @@
</script>
<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>
<div class="flex items-center justify-between">
<div>
@@ -145,7 +149,9 @@
{/if}
</div>
{#if selectedBooking}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(
selectedBooking.status
)}
{#if selectedBooking.status === 'pending'}
<div class="flex items-center gap-2">
@@ -175,14 +181,14 @@
Pending
</span>
<!-- Deposit Badge for pending -->
{#if selectedBooking.deposit_required}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
Will Require Deposit
</span>
{/if}
<!-- Deposit Badge for pending -->
{#if selectedBooking.deposit_required}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
Will Require Deposit
</span>
{/if}
</div>
{:else if isConfirmedOrLater && selectedBooking.deposit_required}
<!-- Deposit Badge for confirmed+ -->
@@ -197,14 +203,12 @@
>
{selectedBooking.status.replace('_', ' ')}
</span>
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
</div>
{/if}
{/if}
@@ -270,8 +274,14 @@
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
{/if}
<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="font-semibold">£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span>
<span class="text-gray-600"
>{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>
{/each}
@@ -296,7 +306,9 @@
<div>
<div class="text-lg font-semibold">{selectedBooking.user?.full_name || '—'}</div>
{#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}
</div>
</div>
@@ -308,7 +320,8 @@
<a
href="mailto:{selectedBooking.user.email}"
class="font-medium break-words text-blue-600 hover:underline"
>{selectedBooking.user.email}</a>
>{selectedBooking.user.email}</a
>
{:else}
<div class="font-medium"></div>
{/if}
@@ -318,8 +331,8 @@
{#if selectedBooking.user?.phone}
<a
href="tel:{selectedBooking.user.phone}"
class="font-medium text-blue-600 hover:underline"
>{selectedBooking.user.phone}</a>
class="font-medium text-blue-600 hover:underline">{selectedBooking.user.phone}</a
>
{:else}
<div class="font-medium"></div>
{/if}
@@ -328,19 +341,26 @@
<div>
<div class="text-xs text-gray-500">Referral Code</div>
<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}
</code>
<button
type="button"
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={() => {
navigator.clipboard.writeText(selectedBooking.user!.referral_code!);
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" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
@@ -368,30 +388,30 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Financial Summary
</h3>
<div class="space-y-2">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<!-- Deposit Info -->
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
<div class="text-xs">
<span
class="{selectedBooking.deposit_paid
? 'text-green-600'
: 'text-orange-600'}"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
<div class="space-y-2">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<!-- Deposit Info -->
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
</div>
<div class="text-xs">
<span
class={selectedBooking.deposit_paid ? 'text-green-600' : 'text-orange-600'}
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {formatDateTime(selectedBooking.deposit_deadline)}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {formatDateTime(selectedBooking.deposit_deadline)}
</span>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span>
@@ -494,10 +514,7 @@
<Modal.Footer class="flex items-center justify-end gap-2">
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit'].includes(selectedBooking.status)}
<Button
variant="outline"
onclick={() => (showRescheduleModal = true)}
>
<Button variant="outline" onclick={() => (showRescheduleModal = true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
@@ -506,7 +523,9 @@
stroke="currentColor"
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>
Reschedule
</Button>
@@ -246,35 +246,33 @@
<div class="font-medium">
{formatBookingDateTime(b.start_time)}
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span>
{b.status}
</span>
{#if b.deposit_required}
{#if b.status === 'pending'}
<span
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
</span>
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{b.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span>
{b.status}
</span>
{#if b.deposit_required}
{#if b.status === 'pending'}
<span
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
</span>
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{b.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
>
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
<span>{b.user?.full_name || 'Unknown User'}</span>
<span>
- {formatServices(b.services)}
</span>
</div>
{/if}
<span>{b.user?.full_name || 'Unknown User'}</span>
<span>
- {formatServices(b.services)}
</span>
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
@@ -1,8 +1,8 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
@@ -172,7 +172,10 @@ import { formatDuration } from '$lib/utils/format';
overrideOriginalPrice = service.price ?? 0;
overrideOriginalDuration = service.duration_minutes ?? 0;
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;
}
@@ -299,8 +302,7 @@ import { formatDuration } from '$lib/utils/format';
service_ids: services.map((s) => s.service_id),
service_overrides: services
.filter(
(s) =>
s.override_price !== undefined || s.override_duration_minutes !== undefined
(s) => s.override_price !== undefined || s.override_duration_minutes !== undefined
)
.map((s) => ({
service_id: s.service_id,
@@ -391,7 +393,9 @@ import { formatDuration } from '$lib/utils/format';
</script>
<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>
<div class="flex items-center justify-between">
<div>
@@ -409,9 +413,7 @@ import { formatDuration } from '$lib/utils/format';
<div class="space-y-6 px-4 pb-4">
<!-- Customer Info -->
<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">
Customer
</h3>
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">Customer</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
@@ -419,7 +421,9 @@ import { formatDuration } from '$lib/utils/format';
</div>
<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 class="text-xs text-gray-500">Phone</div>
@@ -465,17 +469,23 @@ import { formatDuration } from '$lib/utils/format';
</div>
{#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.
</div>
{:else}
<div class="space-y-2">
{#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="font-medium">{service.service_name}</div>
<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>
{#if service.override_price !== undefined || service.override_duration_minutes !== undefined}
<div class="text-xs text-amber-600">Modified</div>
@@ -523,7 +533,9 @@ import { formatDuration } from '$lib/utils/format';
{/each}
</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="font-semibold">£{totalPrice.toFixed(2)}</span>
</div>
@@ -538,10 +550,16 @@ import { formatDuration } from '$lib/utils/format';
</h3>
<div class="space-y-2">
{#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="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}
<span class="text-gray-500"> via {payment.payment_method}</span>
{/if}
@@ -550,7 +568,8 @@ import { formatDuration } from '$lib/utils/format';
<span
class:text-green-600={payment.status === 'completed'}
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}
</span>
@@ -562,7 +581,11 @@ import { formatDuration } from '$lib/utils/format';
{/if}
</div>
<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>
{#if payment.status === 'completed'}
@@ -592,9 +615,7 @@ import { formatDuration } from '$lib/utils/format';
class="w-full"
/>
{#if notes.trim() !== (booking.notes || '')}
<div class="mt-1 text-xs text-emerald-600">
Notes will be saved
</div>
<div class="mt-1 text-xs text-emerald-600">Notes will be saved</div>
{/if}
</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.Header>
<Modal.Title class="text-lg font-semibold">Add Service</Modal.Title>
<Modal.Description>
Select a service to add to this booking.
</Modal.Description>
<Modal.Description>Select a service to add to this booking.</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
@@ -742,7 +761,9 @@ import { formatDuration } from '$lib/utils/format';
{#if loadingServices}
<div class="text-center text-sm text-gray-500">Loading services...</div>
{: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}
No services available.
{:else}
@@ -778,16 +799,12 @@ import { formatDuration } from '$lib/utils/format';
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title>
<Modal.Description>
Enter the refund amount and reason.
</Modal.Description>
<Modal.Description>Enter the refund amount and reason.</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div>
<label for="refund-amount" class="mb-1 block text-xs text-gray-600">
Refund Amount
</label>
<label for="refund-amount" class="mb-1 block text-xs text-gray-600"> Refund Amount </label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<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">
<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'}
</Button>
</Modal.Footer>
@@ -221,7 +221,10 @@
{formatDateLine1(editRequest.original.start_time)}
</div>
<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>
{#if isTimeChanged()}
@@ -231,11 +234,14 @@
{formatDateLine1(editRequest.proposed.start_time!)}
</div>
<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>
{:else}
<div class="text-sm italic text-gray-500">No change</div>
<div class="text-sm text-gray-500 italic">No change</div>
{/if}
</div>
</div>
@@ -252,7 +258,7 @@
{#each editRequest.original.services as service (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">
<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="text-sm font-medium text-red-700 line-through">
{service.name}
@@ -264,7 +270,7 @@
</div>
{:else}
<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="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
@@ -281,8 +287,10 @@
<div class="space-y-2">
{#each editRequest.proposed.services as service (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">
<span class="mt-0.5 text-emerald-600 font-mono text-sm">+</span>
<div
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="text-sm font-medium text-emerald-700">{service.name}</div>
<div class="text-xs text-emerald-600">
@@ -292,7 +300,7 @@
</div>
{:else}
<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="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
@@ -322,7 +330,7 @@
{#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes}
<div class="text-sm">{editRequest.proposed.notes}</div>
{:else}
<div class="text-sm italic text-gray-500">No change</div>
<div class="text-sm text-gray-500 italic">No change</div>
{/if}
</div>
</div>
@@ -340,11 +348,7 @@
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="destructive"
onclick={() => (showDenyConfirm = true)}
disabled={submitting}
>
<Button variant="destructive" onclick={() => (showDenyConfirm = true)} disabled={submitting}>
Deny
</Button>
<Button
@@ -754,24 +754,26 @@
/>
</div>
{#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}
{#if input.length && suggestions.length}
<div
class="cursor-pointer px-3 py-3 text-sm touch-manipulation {isHighlighted
? '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"
class="absolute top-full right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md"
>
{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>
{/each}
</div>
{/if}
{/if}
</div>
</div>
@@ -82,12 +82,12 @@
const text = await response.text();
toast.error(text || 'Failed to add patch test', { id: loadingToast });
}
} catch (err) {
console.error('Error adding patch test:', err);
toast.error('Network error', { id: loadingToast });
} finally {
submitting = false;
}
} catch (err) {
console.error('Error adding patch test:', err);
toast.error('Network error', { id: loadingToast });
} finally {
submitting = false;
}
}
$effect(() => {
@@ -33,7 +33,11 @@
let saving = $state(false);
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 selectedTime = $state<string | null>(null);
@@ -55,21 +59,42 @@
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new SvelteDate();
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(
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(
selectedDate ? buildLunchProtection(selectedDate, workingHours, availableHours, bookingDuration, true) : new Map()
selectedDate
? buildLunchProtection(selectedDate, workingHours, availableHours, bookingDuration, true)
: new Map()
);
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 {
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 (bookingDuration <= 0) return true;
// Build lunch protection specifically for the date being checked (not the selected date)
const dayProtection = buildLunchProtection(date as CalendarDate, workingHours, availableHours, bookingDuration, true);
const slots = generateAvailableTimeSlots(date as CalendarDate, workingHours, availableHours, bookingDuration, dayProtection);
const dayProtection = buildLunchProtection(
date as CalendarDate,
workingHours,
availableHours,
bookingDuration,
true
);
const slots = generateAvailableTimeSlots(
date as CalendarDate,
workingHours,
availableHours,
bookingDuration,
dayProtection
);
if (slots.length === 0) return true;
return false;
}
@@ -130,7 +167,9 @@
const whMap: Record<string, DayHours> = {};
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 }));
// Cache by month key
@@ -191,7 +230,11 @@
try {
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([
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
@@ -206,7 +249,9 @@
if (gen !== hoursMonthGeneration) return;
const whMap: Record<string, DayHours> = {};
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 }));
workingHoursCache[monthKey] = whMap;
@@ -220,7 +265,7 @@
toast.error('Failed to load availability');
} finally {
delete loadingMonths[monthKey];
loadingMonthKeys = new Set([...loadingMonthKeys].filter(k => k !== monthKey));
loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey));
loadingHours = false;
}
}
@@ -237,7 +282,11 @@
loadingMonths = {};
loadingMonthKeys = new Set();
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() {
@@ -318,7 +367,14 @@
// Auto-select first available date once data loads (timing-safe, data-driven)
$effect(() => {
if (open && workingHours && availableHours && !selectedDate && !userNavigatedCalendar && !rescheduleAutoSelectDone) {
if (
open &&
workingHours &&
availableHours &&
!selectedDate &&
!userNavigatedCalendar &&
!rescheduleAutoSelectDone
) {
rescheduleAutoSelectDone = true;
const now = new SvelteDate();
const maxDateJs = new SvelteDate(
@@ -334,7 +390,11 @@
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
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)) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
@@ -360,10 +420,21 @@
<Card.Root class="mb-4">
<Card.Content class="pt-4">
<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 class="text-sm text-gray-500 mt-1">
{booking.user?.full_name || 'Unknown'} · {booking.services?.map((s) => s.service_name).join(', ') || 'No services'} · {bookingDuration} min
<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
</div>
</Card.Content>
</Card.Root>
@@ -381,10 +452,10 @@
selectedDate = newDate;
selectedTime = null;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
userNavigatedCalendar = true;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
userNavigatedCalendar = true;
}}
/>
</div>
@@ -393,14 +464,18 @@
<div class="border-t">
<div class="max-h-64 overflow-y-auto p-6">
{#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}
<TimeSlotList
slots={groupedTimeSlots}
{selectedTime}
duration={bookingDuration}
protection={lunchProtection}
onSelect={(time) => { selectedTime = time; }}
onSelect={(time) => {
selectedTime = time;
}}
/>
</div>
</div>
@@ -423,7 +498,14 @@
</div>
<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
</Button>
<Button onclick={reschedule} disabled={saving || !selectedDate || !selectedTime}>
@@ -129,11 +129,21 @@
if (!wh || !wh.isOpen) return [];
const startMin = timeToMinutes(wh.startTime);
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) {
const t = minutesTo12h(m);
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;
});
@@ -144,17 +154,25 @@
const startMin = timeToMinutes(wh.startTime);
const endMin = timeToMinutes(wh.endTime);
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) {
const t = minutesTo12h(m);
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;
});
function formatRelativeTime(iso: string): string {
const d = new SvelteDate(iso);
const now = new SvelteDate();
@@ -174,7 +192,9 @@
}
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)));
@@ -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;
const t24 = to24h(hour, minute, period);
return `${date}T${t24}:00`;
}
let formComplete = $derived.by(() => {
return newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== '';
return (
newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== ''
);
});
let canCreate = $derived.by(() => {
@@ -513,128 +540,135 @@
<Skeleton class="h-16 w-full" />
{/each}
</div>
{:else if blockers.length === 0}
<p class="text-sm text-gray-500">No time blockers found.</p>
{:else}
{#if blockers.length === 0}
<p class="text-sm text-gray-500">No time blockers found.</p>
{:else}
<div class="space-y-3">
{#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="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;
}}
>
<div class="space-y-3">
{#each pagedBlockers as b (b.id)}
<div
class="flex items-center justify-between gap-3 rounded-lg border p-3 transition-all hover:shadow-sm sm:p-4"
>
<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">
<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"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<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" />
<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>
</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>
{/each}
</div>
{#if totalPages > 1}
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between pt-2">
<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)}
<Button
variant="destructive"
size="sm"
class="shrink-0"
onclick={() => {
blockerToDelete = b;
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"
>
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>
<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"
/>
</svg>
</Button>
</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}
</Card.Content>
@@ -668,14 +702,37 @@
<div class="space-y-2">
<label for="blocker-start-date" class="text-xs text-gray-600">Date</label>
<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
</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
</Button>
</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 class="space-y-2">
<label class="text-xs text-gray-600">Time</label>
@@ -684,7 +741,11 @@
{:else if !selectedWorkingHours}
<p class="text-sm text-gray-400">Select a date first</p>
{: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}
<select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
@@ -744,28 +805,51 @@
{#if checkingOverlap}
<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">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Checking for conflicting bookings…
</div>
{:else if hasOverlap}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-2 mb-3">
<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">
<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" />
<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"
>
<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="17" x2="12.01" y2="17" />
</svg>
<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>
</div>
<div class="space-y-3">
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-3">
<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">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
@@ -780,12 +864,12 @@
{/if}
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="mt-2 flex gap-2">
{#if openBookingModal}
<Button
variant="outline"
size="sm"
class="text-xs h-7"
class="h-7 text-xs"
onclick={() => openBookingModal(booking.id)}
>
View Booking
@@ -795,7 +879,7 @@
<Button
variant="outline"
size="sm"
class="text-xs h-7"
class="h-7 text-xs"
onclick={() => openUserModal(booking.user!.id)}
>
View Client
@@ -832,8 +916,8 @@
<AlertDialog.Header>
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
<AlertDialog.Description>
This will remove the "{blockerToDelete?.description}" time blocker. Bookings may become available
during this period. This action cannot be undone.
This will remove the "{blockerToDelete?.description}" time blocker. Bookings may become
available during this period. This action cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -230,7 +230,9 @@
</script>
<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>
<div class="flex items-center justify-between">
<div>
@@ -270,7 +272,9 @@
</div>
<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 class="text-xs text-gray-500">Phone</div>
@@ -334,7 +338,7 @@
<img
src={selectedUser.profilePicUrl}
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>
{/if}
@@ -381,50 +385,50 @@
return `${dateStr} at ${timeStr}`;
})()}
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{booking.status === 'confirmed' || booking.status === 'in_progress'
? 'bg-emerald-100 text-emerald-800'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: booking.status === 'completed'
? 'bg-green-100 text-green-800'
: booking.status === 'client_cancelled' ||
booking.status === 'we_cancelled' ||
booking.status === 'no_deposit'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}"
>
{booking.status.replace('_', ' ')}
</span>
? 'bg-emerald-100 text-emerald-800'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: booking.status === 'completed'
? 'bg-green-100 text-green-800'
: booking.status === 'client_cancelled' ||
booking.status === 'we_cancelled' ||
booking.status === 'no_deposit'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}"
>
{booking.status.replace('_', ' ')}
</span>
{#if booking.deposit_required}
{#if booking.status === 'pending'}
<span
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
</span>
{:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid}
<span
class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800"
>
Deposit Due
</span>
{:else if booking.deposit_paid}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
>
Deposit Paid
</span>
{#if booking.deposit_required}
{#if booking.status === 'pending'}
<span
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
</span>
{:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid}
<span
class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800"
>
Deposit Due
</span>
{:else if booking.deposit_paid}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
>
Deposit Paid
</span>
{/if}
{/if}
{/if}
<span class="text-gray-500">
{booking.services.map((s) => s.service_name).join(', ')}
</span>
</div>
<span class="text-gray-500">
{booking.services.map((s) => s.service_name).join(', ')}
</span>
</div>
<div class="mt-1 text-sm font-semibold text-gray-900">
£{booking.total_amount.toFixed(2)}
</div>
@@ -484,19 +488,27 @@
<div class="grid gap-3 md:grid-cols-3">
<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 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 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 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>
@@ -505,7 +517,9 @@
<div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div>
<div class="flex flex-wrap gap-2">
{#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}
<span class="ml-1 text-xs text-blue-600">({service.count})</span>
</span>
@@ -538,21 +552,28 @@
</div>
{#if hasEligiblePatchTests}
<!-- Patch Test Actions -->
<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">
Patch Tests
</h3>
<p class="mb-3 text-sm text-gray-600">
Record patch test completion to allow this user to book services requiring one.
</p>
<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">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Record Patch Test
</Button>
</div>
<!-- Patch Test Actions -->
<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">
Patch Tests
</h3>
<p class="mb-3 text-sm text-gray-600">
Record patch test completion to allow this user to book services requiring one.
</p>
<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"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Record Patch Test
</Button>
</div>
{/if}
</div>
{/if}
@@ -38,9 +38,7 @@
});
if (response.ok) {
const services: Service[] = await response.json();
const durations = services
.map((s) => s.duration_minutes)
.filter((d) => d > 0);
const durations = services.map((s) => s.duration_minutes).filter((d) => d > 0);
if (durations.length > 0) {
shortestServiceMinutes = Math.min(...durations);
}
@@ -355,14 +353,19 @@
{#if tooShortForService}
<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>
{/if}
<div class="flex flex-wrap items-center gap-3">
<Button
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'}
</Button>
@@ -374,7 +377,7 @@
bind:open={showCreateModal}
maxSlotDuration={reservedDuration}
availableStartTime={undefined}
reservationExpiresAt={reservationExpiresAt}
{reservationExpiresAt}
onclose={handleModalClose}
/>
{/if}
@@ -107,9 +107,7 @@
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration);
const canProceedStep1 = $derived(
userType === 'member' ? !!selectedUserId : !!guestName.trim()
);
const canProceedStep1 = $derived(userType === 'member' ? !!selectedUserId : !!guestName.trim());
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
// =============== Effects ===============
@@ -277,35 +275,35 @@
return;
}
let finalUserId = selectedUserId;
let finalUserId = selectedUserId;
if (userType === 'guest') {
const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Walk-in',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Guest',
phone: phone,
email: `walkin-${Date.now()}@guest.invalid`
})
});
if (userType === 'guest') {
const phone = guestPhone.trim() || '+447700900000';
const createRes = await fetch('/api/users/guest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
firstName: guestName.trim().split(' ')[0] || 'Walk-in',
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Guest',
phone: phone,
email: `walkin-${Date.now()}@guest.invalid`
})
});
if (!createRes.ok) {
toast.error('Failed to create guest user');
submitting = false;
return;
if (!createRes.ok) {
toast.error('Failed to create guest user');
submitting = false;
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');
// Use the available slot start time from the widget
let start: Date;
@@ -414,7 +412,9 @@
</script>
<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.Title>Walk-In Booking</Modal.Title>
<Modal.Description>
@@ -425,26 +425,39 @@
<div class="px-6 pb-4">
<!-- Reservation Countdown Banner -->
{#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 gap-2">
<svg 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
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>
<span class="text-sm font-medium text-green-800">
Slot held for
</span>
<span class="text-sm font-medium text-green-800"> Slot held for </span>
</div>
<span class="text-sm font-mono font-semibold text-green-700">
<span class="font-mono text-sm font-semibold text-green-700">
{reservationCountdown}
</span>
</div>
</div>
{: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">
<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>
<span class="text-sm font-medium text-red-800">
Slot released — please re-check availability
@@ -600,17 +613,17 @@
oninput={(e) => (guestName = e.currentTarget.value)}
/>
</div>
<div class="space-y-2">
<Label for="guest-phone">Phone Number</Label>
<input
id="guest-phone"
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"
placeholder="07700 900000 (optional — helps us reach you if running late)"
value={guestPhone}
oninput={(e) => (guestPhone = e.currentTarget.value)}
/>
</div>
<div class="space-y-2">
<Label for="guest-phone">Phone Number</Label>
<input
id="guest-phone"
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"
placeholder="07700 900000 (optional — helps us reach you if running late)"
value={guestPhone}
oninput={(e) => (guestPhone = e.currentTarget.value)}
/>
</div>
<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
loyalty benefits.
@@ -487,7 +487,9 @@
>
{#each Array(24) as _, hour}
{#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}
</select>
@@ -500,7 +502,9 @@
>
{#each Array(24) as _, hour}
{#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}
</select>
@@ -1358,7 +1358,12 @@
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
</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}
<Card.Root class="border-fuchsia-200 bg-fuchsia-50">
@@ -1371,12 +1376,14 @@
Guest checkout does not receive loyalty stamps or seasonal discounts.
</p>
<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
</Button>
<Button onclick={nextStep}>
Continue as Guest
</Button>
<Button onclick={nextStep}>Continue as Guest</Button>
</div>
</Card.Content>
</Card.Root>
@@ -25,7 +25,7 @@
<Calendar
type="single"
value={date}
placeholder={placeholder}
{placeholder}
// preventDeselect tells bits-ui to never deselect an already-selected date
// (which would fire onValueChange(undefined) and desync the $bindable() chain)
preventDeselect={true}
@@ -15,13 +15,28 @@
<Separator class="my-4" />
{#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">
<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">
<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
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>
<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'}
</div>
<div class="mt-1 text-sm {protection?.isBlocked ? 'text-red-700' : 'text-amber-700'}">
@@ -31,9 +46,15 @@
</div>
</div>
{/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">
{#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 class="mt-1 text-sm text-emerald-700">
{selectedDate} at {selectedTime}
@@ -26,7 +26,7 @@
onclick={() => !isGrayedOut && onclick?.()}
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>
<h3 class="font-semibold">{service.name}</h3>
{#if isGrayedOut}
@@ -44,7 +44,7 @@
<p class="text-sm text-gray-600">{service.description}</p>
{/if}
</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 class="font-semibold text-foreground">£{service.price}</span>
</div>
@@ -7,7 +7,9 @@
const totalSteps = steps.length;
</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)}
{@const displayNumber = startAt + index}
{@const isActive = displayNumber <= currentStep}
@@ -27,7 +27,7 @@
title={p.warningMessage || 'Lunch protection'}
>
{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>
</Button>
{:else}
@@ -36,20 +36,33 @@
onclick={() => onSelect(slot.startTime)}
class={`w-full hover:bg-fuchsia-50 ${
slot.startTime === selectedTime
? (p?.showWarning ? 'bg-fuchsia-200 border-amber-500' : 'bg-fuchsia-100')
: (p?.showWarning ? 'border-amber-400 bg-amber-100' : '')
? p?.showWarning
? 'border-amber-500 bg-fuchsia-200'
: 'bg-fuchsia-100'
: p?.showWarning
? 'border-amber-400 bg-amber-100'
: ''
}`}
title={p?.warningMessage}
>
{#if p?.showWarning}
<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">
<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
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>
</span>
{/if}
{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>
</Button>
{/if}
@@ -60,11 +73,11 @@
disabled
>
{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>
{/if}
{/each}
</div>
{: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}
@@ -15,7 +15,17 @@
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 = {
checkout_id: string;
@@ -84,9 +94,9 @@
let tipPercentages = $derived.by(() => {
if (subtotal <= 0) 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: 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;
}
let subtotal = $derived(
(booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0)
);
let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0));
let tipMultiplier = $derived(
selectedTipPercent !== null
@@ -415,8 +423,12 @@
});
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()} onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Content class="max-w-lg max-h-[90vh] overflow-y-auto">
<Dialog.Root
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.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
</Dialog.Header>
@@ -425,10 +437,16 @@
<div class="space-y-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="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}`)}
<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">
<span class="text-xs text-gray-500">£</span>
<input
@@ -436,7 +454,9 @@
inputmode="decimal"
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"
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)}
/>
{#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">
<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'}"
onclick={() => { selectedMethod = 'card'; status = 'selecting'; }}
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'}"
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" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
@@ -480,10 +512,22 @@
</button>
<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'}"
onclick={() => { selectedMethod = 'cash'; status = 'cash-entering'; }}
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'}"
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" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
@@ -491,10 +535,22 @@
</button>
<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'}"
onclick={() => { selectedMethod = 'giftcard'; status = 'gift-entering'; }}
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'}"
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" />
<rect x="2" y="7" width="20" height="5" />
<line x1="12" y1="22" x2="12" y2="7" />
@@ -509,19 +565,19 @@
<button
type="button"
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
</button>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1">
Cancel
</Button>
<Button variant="ghost" onclick={handleClose} class="flex-1">Cancel</Button>
</div>
</div>
{:else if status === 'selecting'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
@@ -537,8 +593,8 @@
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
tip.pct
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}"
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}"
onclick={() => selectTipPercent(tip.pct)}
>
<div>{tip.pct}%</div>
@@ -547,7 +603,7 @@
{/each}
</div>
<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
type="text"
inputmode="decimal"
@@ -572,25 +628,20 @@
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
Back
</Button>
<Button
onclick={handleCardPayment}
class="flex-1 bg-green-600 hover:bg-green-700"
>
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleCardPayment} class="flex-1 bg-green-600 hover:bg-green-700">
Charge Card
</Button>
</div>
</div>
{:else if status === 'card-processing' || status === 'card-polling'}
<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="mt-2 text-sm text-gray-500">This may take a few moments</p>
</div>
{:else if status === 'cash-entering'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
@@ -599,11 +650,9 @@
</div>
<div>
<label for="cash-amount" class="text-sm font-medium text-gray-700">
Cash Received
</label>
<label for="cash-amount" class="text-sm font-medium text-gray-700"> Cash Received </label>
<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
id="cash-amount"
type="text"
@@ -624,10 +673,7 @@
</div>
{#if changeDue > 0}
<div class="mt-2 flex items-center gap-2">
<Checkbox
id="keep-change"
bind:checked={extraAsTip}
/>
<Checkbox id="keep-change" bind:checked={extraAsTip} />
<label for="keep-change" class="text-sm text-green-700">
Keep {formatCurrency(changeDue)} as tip
</label>
@@ -637,9 +683,7 @@
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
Back
</Button>
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleCashPayment}
class="flex-1 bg-green-600 hover:bg-green-700"
@@ -649,13 +693,13 @@
</Button>
</div>
</div>
{:else if status === 'cash-confirming'}
<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>
</div>
{:else if status === 'gift-entering'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
@@ -664,9 +708,7 @@
</div>
<div>
<label for="gift-card-id" class="text-sm font-medium text-gray-700">
Gift Card ID
</label>
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card ID </label>
<Input
id="gift-card-id"
type="text"
@@ -682,9 +724,7 @@
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
Back
</Button>
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button
onclick={handleGiftCardPayment}
class="flex-1 bg-green-600 hover:bg-green-700"
@@ -694,28 +734,23 @@
</Button>
</div>
</div>
{:else if status === 'gift-confirming'}
<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>
</div>
{:else if status === 'error' && error}
<div class="space-y-4">
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1">
Close
</Button>
<Button onclick={resetToSelect} class="flex-1">
Try Again
</Button>
<Button variant="ghost" onclick={handleClose} class="flex-1">Close</Button>
<Button onclick={resetToSelect} class="flex-1">Try Again</Button>
</div>
</div>
{:else if status === 'success' && paymentResult}
<div class="space-y-4">
<div class="flex flex-col items-center justify-center py-4">
@@ -759,9 +794,7 @@
</div>
</div>
<Button onclick={handleClose} class="w-full">
Done
</Button>
<Button onclick={handleClose} class="w-full">Done</Button>
</div>
{/if}
</Dialog.Content>
@@ -92,32 +92,31 @@
let expiryParts = $derived(parseExpiryParts(newCardExpiry));
let isExpiryInPast = $derived(
expiryParts !== null && (() => {
const expiryDate = new SvelteDate(expiryParts.year, expiryParts.month);
return expiryDate < new SvelteDate();
})()
expiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(expiryParts.year, expiryParts.month);
return expiryDate < new SvelteDate();
})()
);
let hasInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null
);
let hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
let cardFormValid = $derived(
newCardNumber.replace(/\s/g, '').length >= 13 &&
expiryParts !== null &&
newCardCVC.length >= 3 &&
!isExpiryInPast
expiryParts !== null &&
newCardCVC.length >= 3 &&
!isExpiryInPast
);
let cardSelected = $derived(
(selectedPaymentMethod !== null && paymentMethods.length > 0) ||
(showNewCardForm && cardFormValid) ||
(paymentMethods.length === 0 && cardFormValid)
(showNewCardForm && cardFormValid) ||
(paymentMethods.length === 0 && cardFormValid)
);
let cardValidationError = $derived(
!cardSelected ? (
selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
!cardSelected
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
? 'Please select a card'
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
? 'Card number too short'
@@ -132,7 +131,7 @@
: paymentMethods.length === 0 && !showNewCardForm && newCardNumber.length === 0
? 'Please enter card details'
: 'Please complete all card fields'
) : null
: null
);
// Partial payment amount (in pounds, user enters)
@@ -143,9 +142,7 @@
let pollingInterval: ReturnType<typeof setInterval> | null = null;
// Derived values
let depositOutstanding = $derived(
booking.deposit_required && !booking.deposit_paid
);
let depositOutstanding = $derived(booking.deposit_required && !booking.deposit_paid);
let totalPaid = $derived(
booking.payments
@@ -157,23 +154,21 @@
let isScenarioA = $derived(depositOutstanding);
let isScenarioB = $derived(
!depositOutstanding && totalPaid < booking.total_amount
);
let isScenarioB = $derived(!depositOutstanding && totalPaid < booking.total_amount);
let partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
let partialAmountValid = $derived(
paymentType === 'partial' &&
partialAmount !== '' &&
!isNaN(partialAmountNum) &&
partialAmountNum > 0 &&
partialAmountNum <= amountRemaining &&
/^\d+(\.\d{0,2})?$/.test(partialAmount)
partialAmount !== '' &&
!isNaN(partialAmountNum) &&
partialAmountNum > 0 &&
partialAmountNum <= amountRemaining &&
/^\d+(\.\d{0,2})?$/.test(partialAmount)
);
let partialValidationError = $derived(
paymentType === 'partial' && !partialAmountValid ? (
partialAmount === ''
paymentType === 'partial' && !partialAmountValid
? partialAmount === ''
? 'Enter an amount'
: !/^\d+(\.\d{0,2})?$/.test(partialAmount)
? 'Invalid amount format'
@@ -182,7 +177,7 @@
: partialAmountNum > amountRemaining
? 'Amount exceeds balance'
: 'Invalid amount'
) : null
: null
);
let payButtonDisabled = $derived(
@@ -190,9 +185,13 @@
);
let payButtonError = $derived(
status === 'processing' ? null : (
!cardSelected ? cardValidationError : (paymentType === 'partial' ? partialValidationError : null)
)
status === 'processing'
? null
: !cardSelected
? cardValidationError
: paymentType === 'partial'
? partialValidationError
: null
);
function formatCurrency(pence: number): string {
@@ -388,7 +387,7 @@
</script>
<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.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
{#if booking.id}
@@ -406,7 +405,11 @@
<div class="flex justify-between text-sm">
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
<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>
</div>
{/each}
@@ -417,7 +420,8 @@
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm">
<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 class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
@@ -441,9 +445,13 @@
{#if selectedPaymentMethod}
{#each paymentMethods as method (method.id)}
{#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 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}
</div>
<div class="text-sm">
@@ -474,7 +482,7 @@
{#if canSaveCards}
<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={() => {
showNewCardForm = true;
showCardList = false;
@@ -482,7 +490,13 @@
}}
>
<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" />
</svg>
</button>
@@ -490,7 +504,10 @@
{#each paymentMethods as method (method.id)}
<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={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
@@ -498,7 +515,9 @@
}}
>
<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}
</div>
<div class="text-sm">
@@ -522,7 +541,9 @@
<h4 class="mb-3 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-3">
<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
id="cardNumber"
type="text"
@@ -536,7 +557,9 @@
</div>
<div class="grid grid-cols-2 gap-3">
<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
id="cardExpiry"
type="text"
@@ -582,14 +605,20 @@
<div class="grid grid-cols-2 gap-3">
<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')}
>
Pay Deposit
</button>
<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')}
>
Pay in Full
@@ -601,13 +630,17 @@
<p class="text-sm text-red-600">{payButtonError}</p>
{/if}
<Button
onclick={() => paymentType === 'deposit' ? handlePayDeposit() : handlePayFull()}
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled}
>
{#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}
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
{/if}
@@ -622,14 +655,20 @@
<div class="grid grid-cols-2 gap-3">
<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')}
>
Pay in Full
</button>
<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')}
>
Pay Part
@@ -643,7 +682,7 @@
Partial Payment Amount
</label>
<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
id="partial-amount"
type="text"
@@ -664,13 +703,15 @@
<p class="text-sm text-red-600">{payButtonError}</p>
{/if}
<Button
onclick={() => paymentType === 'partial' ? handlePayPartial() : handlePayFull()}
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled}
>
{#if paymentType === 'partial'}
Pay {partialAmountValid ? formatCurrency(Math.round(partialAmountNum * 100)) : 'Part'}
Pay {partialAmountValid
? formatCurrency(Math.round(partialAmountNum * 100))
: 'Part'}
{:else}
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
{/if}
@@ -685,19 +726,24 @@
{/if}
<!-- 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
</Button>
</div>
{:else if status === 'polling'}
<!-- Polling State -->
<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="mt-2 text-sm text-gray-500">This may take a few moments</p>
<Button variant="ghost" onclick={handleClose} class="mt-6">
Cancel
</Button>
<Button variant="ghost" onclick={handleClose} class="mt-6">Cancel</Button>
</div>
{:else if status === 'success' && paymentResult}
<!-- Success State -->
@@ -750,9 +796,7 @@
</div>
</div>
<Button onclick={handleClose} class="w-full">
Done
</Button>
<Button onclick={handleClose} class="w-full">Done</Button>
</div>
{/if}
</Dialog.Content>
@@ -89,8 +89,18 @@
if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number);
const today = new SvelteDate();
const closing = new SvelteDate(today.getFullYear(), today.getMonth() + 1, today.getDate(), ch, cm, 0);
const minutesToClosing = Math.max(0, Math.floor((closing.getTime() - endTime.getTime()) / 60000));
const closing = new SvelteDate(
today.getFullYear(),
today.getMonth() + 1,
today.getDate(),
ch,
cm,
0
);
const minutesToClosing = Math.max(
0,
Math.floor((closing.getTime() - endTime.getTime()) / 60000)
);
if (!nextAppointment) {
// No next appointment — show time until closing
@@ -213,7 +223,7 @@
{#if activeAppointment}
{#if isInProgress}
<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="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
@@ -224,18 +234,19 @@
</Badge>
{#if timeRemaining > 29}
{#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
</Badge>
{:else if freeTimeAfter > 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap">
{freeTimeAfter} min free afterwards{#if freeTimeCapped} (closing after){/if}
<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}
</Badge>
{/if}
{/if}
</div>
{: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
</Badge>
{/if}
@@ -246,7 +257,7 @@
{#if loading}
<Card.Content class="space-y-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">
<Skeleton class="h-6 w-48" />
<Skeleton class="h-4 w-32" />
@@ -280,23 +291,29 @@
<Card.Content>
<div class="grid gap-6 md:grid-cols-3">
<!-- 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}
<img
src={activeAppointment.user.profile_pic_url}
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}
{@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
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}
</div>
{/if}
<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>
{#if activeAppointment.user}
<button
@@ -321,7 +338,11 @@
<div class="text-xs text-gray-600">{service.service_description}</div>
{/if}
<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>
{/each}
@@ -413,7 +434,11 @@
</svg>
Cancel
</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
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
@@ -437,5 +462,9 @@
</Card.Root>
{#if showPaymentModal}
<PaymentModal booking={activeAppointment} onClose={() => showPaymentModal = false} onComplete={handlePaymentComplete} />
<PaymentModal
booking={activeAppointment}
onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete}
/>
{/if}
@@ -93,7 +93,9 @@
let selectedEditRequest = $state<EditRequest | null>(null);
let _hasItems = $derived(pendingApprovals.length > 0 || pendingEditRequests.length > 0);
$effect(() => { hasItems = _hasItems; });
$effect(() => {
hasItems = _hasItems;
});
// Helper function to format date nicely
function formatDateTime(dateTimeString: string): string {
@@ -396,7 +398,7 @@
<div class="flex-1">
<div class="flex items-center gap-2">
<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 class="mt-1 text-sm text-gray-600">
{getEditRequestSummary(er)}
@@ -10,7 +10,11 @@
import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/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';
interface Props {
@@ -125,8 +129,6 @@
return { hour, minute, period };
}
function isAutoGenerated(blocker: TimeBlocker): boolean {
return blocker.description?.startsWith('RESERVATION:') ?? false;
}
@@ -136,7 +138,13 @@
if (!wh || !wh.isOpen) return [];
const startMin = timeToMinutes(wh.startTime);
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) {
const t = minutesTo12h(m);
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
@@ -149,8 +157,18 @@
if (!wh || !wh.isOpen) return [];
const endMin = timeToMinutes(wh.endTime);
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
const options: Array<{ 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 options: Array<{
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);
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
}
@@ -195,7 +213,13 @@
const largestGap = gapInfos[0];
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}`;
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 = {
@@ -257,7 +281,11 @@
startMinutes: suggestedLunch.startMin,
endMinutes: suggestedLunch.endMin,
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 {
const dateParam = today;
const [blockersRes, whRes, ahRes] = await Promise.all([
fetch(`/api/admin/time-blockers?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}` }
})
fetch(
`/api/admin/time-blockers?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) {
@@ -357,33 +403,58 @@
async function checkOverlappingBookings() {
const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
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 blockerEnd = new SvelteDate(endIso);
if (blockerEnd.getTime() <= blockerStart.getTime()) { overlappingBookings = []; hasOverlap = false; return; }
if (blockerEnd.getTime() <= blockerStart.getTime()) {
overlappingBookings = [];
hasOverlap = false;
return;
}
checkingOverlap = true;
try {
const response = await fetch(`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
});
const response = await fetch(
`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
const data = await response.json();
const allBookings: OverlappingBooking[] = data.bookings || [];
const filtered = allBookings.filter((b) => {
const bStart = new SvelteDate(b.start_time);
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
const bStart = new SvelteDate(b.start_time);
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
return bStart < blockerEnd && bEnd > blockerStart;
});
overlappingBookings = filtered;
hasOverlap = overlappingBookings.length > 0;
} else { overlappingBookings = []; hasOverlap = false; }
} else {
overlappingBookings = [];
hasOverlap = false;
}
} catch (err) {
console.error('Error checking overlapping bookings:', err);
overlappingBookings = []; hasOverlap = false;
} finally { checkingOverlap = false; }
overlappingBookings = [];
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;
const t24 = to24h(hour, minute, period);
return `${date}T${t24}:00`;
@@ -404,8 +475,15 @@
try {
const response = await fetch('/api/admin/time-blockers', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` },
body: JSON.stringify({ start_time: start.toISOString(), duration_minutes: durationMinutes, description: newDescription.trim() || 'break' })
headers: {
'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) {
toast.success('Time blocker created!', { id: loadingToast });
@@ -419,7 +497,9 @@
} catch (err) {
console.error('Error creating time blocker:', err);
toast.error('Network error creating time blocker', { id: loadingToast });
} finally { creating = false; }
} finally {
creating = false;
}
}
async function confirmDeleteBlocker() {
@@ -447,9 +527,14 @@
function resetCreateForm() {
newDescription = '';
startHour = '9'; startMinute = '00'; startPeriod = 'AM';
endHour = '10'; endMinute = '00'; endPeriod = 'AM';
overlappingBookings = []; hasOverlap = false;
startHour = '9';
startMinute = '00';
startPeriod = 'AM';
endHour = '10';
endMinute = '00';
endPeriod = 'AM';
overlappingBookings = [];
hasOverlap = false;
}
function openCreateModal() {
@@ -457,8 +542,12 @@
if (workingHours && workingHours.isOpen) {
const startOpt = minutesTo12h(timeToMinutes(workingHours.startTime));
const endOpt = minutesTo12h(timeToMinutes(workingHours.startTime) + 60);
startHour = startOpt.hour; startMinute = startOpt.minute; startPeriod = startOpt.period;
endHour = endOpt.hour; endMinute = endOpt.minute; endPeriod = endOpt.period;
startHour = startOpt.hour;
startMinute = startOpt.minute;
startPeriod = startOpt.period;
endHour = endOpt.hour;
endMinute = endOpt.minute;
endPeriod = endOpt.period;
}
showCreateModal = true;
}
@@ -551,7 +640,14 @@
<Card.Description>Timeline view of all bookings</Card.Description>
</div>
<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="5" y1="12" x2="19" y2="12" />
</svg>
@@ -563,15 +659,15 @@
{#if loading}
<div class="space-y-3">
{#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-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-3 w-48" />
</div>
<Skeleton class="h-6 w-20 hidden sm:block shrink-0" />
<Skeleton class="h-8 w-9 sm:w-16 shrink-0" />
<Skeleton class="hidden h-6 w-20 shrink-0 sm:block" />
<Skeleton class="h-8 w-9 shrink-0 sm:w-16" />
</div>
{/each}
</div>
@@ -599,38 +695,51 @@
{#each timeline as item (item.id)}
{#if item.type === 'appointment'}
<div
class="flex items-center gap-3 sm:gap-4 rounded-lg border p-3 transition-all hover:shadow-md
{isPastAppointment(item.data.start_time, item.data.duration_minutes) ? 'line-through opacity-50' : ''}"
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'
: ''}"
>
<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)}
</div>
<div class="h-10 w-1 shrink-0 rounded {getStatusBarColor(item.data.status)}"></div>
<div class="min-w-0 flex-1">
<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)}
>
{item.data.user_name}
</button>
<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="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)}
</span>
</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)}
</Badge>
<Button
size="sm"
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)}
>
<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" />
<circle cx="12" cy="12" r="3" />
</svg>
@@ -638,39 +747,77 @@
</Button>
</div>
{: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 class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-gray-700">
<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 class="min-w-[60px] text-sm font-semibold text-gray-700 sm:min-w-[80px]">
{formatTime(item.data.start_time)}
</div>
<div 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">
<div
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" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<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>
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
<div class="truncate text-sm font-medium text-gray-900">
{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)}
</span>
</div>
<Button
variant="destructive" size="sm" class="shrink-0 h-9 w-9 p-0"
onclick={() => { blockerToDelete = item.data; showDeleteAlert = true; }}
variant="destructive"
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" />
<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>
</Button>
</div>
{:else if item.type === 'lunch'}
<div class="flex items-center gap-4 rounded-lg border border-green-200 bg-green-50 p-3">
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-green-700">
<div
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}
</div>
<div 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">
<div
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="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" />
<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-2">
<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>
</div>
<Separator />
@@ -718,7 +870,17 @@
{:else if !workingHours.isOpen}
<p class="text-sm text-red-500">Closed today</p>
{: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}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each}
@@ -736,7 +898,17 @@
{:else if availableEndOptions.length === 0}
<p class="text-sm text-gray-400">No available end time after selected start</p>
{: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}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each}
@@ -746,16 +918,36 @@
{#if checkingOverlap}
<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">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Checking for conflicting bookings…
</div>
{:else if hasOverlap}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3">
<div class="flex items-center gap-2 mb-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">
<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" />
<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"
>
<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="17" x2="12.01" y2="17" />
</svg>
@@ -765,17 +957,32 @@
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-2">
<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">
{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)}
{#if booking.services?.length} {' · '}{booking.services.join(', ')}{/if}
{#if booking.services?.length}
{' · '}{booking.services.join(', ')}{/if}
</div>
</div>
<div class="flex gap-2 mt-1.5">
<Button variant="outline" size="sm" class="text-xs h-6" onclick={() => openBookingModal(booking.id)}>View Booking</Button>
<div class="mt-1.5 flex gap-2">
<Button
variant="outline"
size="sm"
class="h-6 text-xs"
onclick={() => openBookingModal(booking.id)}>View Booking</Button
>
{#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}
</div>
</div>
@@ -785,7 +992,14 @@
{/if}
</div>
<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}>
{creating ? 'Creating…' : 'Create Blocker'}
</Button>
@@ -797,11 +1011,21 @@
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<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.Footer>
<AlertDialog.Cancel onclick={() => { showDeleteAlert = false; blockerToDelete = null; }}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmDeleteBlocker} class="bg-red-600 hover:bg-red-700">Delete</AlertDialog.Action>
<AlertDialog.Cancel
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.Content>
</AlertDialog.Root>
@@ -57,9 +57,15 @@
d.setDate(d.getDate() - i);
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)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
});
const res = await fetch(
`/api/scheduling/working-hours?start=${encodeURIComponent(dateStr)}&end=${encodeURIComponent(dateStr)}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (res.ok) {
const data: DayWorkingHours[] = await res.json();
@@ -88,11 +94,20 @@
const dateParam = today;
const [apptsRes, whRes] = await Promise.all([
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)}`, {
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}`
}
}
)
]);
let appointments: TodayAppointment[] = [];
@@ -111,34 +126,60 @@
const total = appointments.length;
const completed = appointments.filter((a) => a.status === 'completed').length;
const inProgress = appointments.filter((a) => a.status === 'in_progress').length;
const upcoming = appointments.filter((a) => a.status === 'confirmed' || a.status === 'pending').length;
const cancelled = appointments.filter((a) => a.status === 'client_cancelled' || a.status === 'we_cancelled').length;
const upcoming = appointments.filter(
(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;
let occupancyPercent = 0;
if (workingHours?.isOpen) {
const workingMinutes = timeToMinutes(workingHours.endTime) - timeToMinutes(workingHours.startTime);
const workingMinutes =
timeToMinutes(workingHours.endTime) - timeToMinutes(workingHours.startTime);
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);
occupancyPercent = workingMinutes > 0 ? Math.round((bookedMinutes / workingMinutes) * 100) : 0;
occupancyPercent =
workingMinutes > 0 ? Math.round((bookedMinutes / workingMinutes) * 100) : 0;
}
const { cutoff, spansClosed } = await findLastWorkingDayClose();
const nowISO = new SvelteDate().toISOString();
let bookingsMade = 0;
try {
const bmRes = await fetch(`/api/admin/bookings/by-created-range?start=${encodeURIComponent(cutoff)}&end=${encodeURIComponent(nowISO)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
});
const bmRes = await fetch(
`/api/admin/bookings/by-created-range?start=${encodeURIComponent(cutoff)}&end=${encodeURIComponent(nowISO)}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (bmRes.ok) {
const data = await bmRes.json();
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);
if (newJson !== prevJson) {
stats = newStats;
@@ -223,7 +264,9 @@
{#if stats.bookingsMade > 0}
<div class="flex items-center justify-between">
<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 class="font-semibold">{stats.bookingsMade}</span>
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,6 +13,6 @@
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)}
class={cn(buttonVariants({ variant: 'outline' }), className)}
{...restProps}
/>
@@ -19,7 +19,7 @@
bind:ref
data-slot="alert-dialog-content"
class={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)}
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="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}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)}
class={cn('text-lg font-semibold', className)}
{...restProps}
/>
@@ -1,5 +1,5 @@
<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();
</script>
@@ -1,13 +1,13 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import Trigger from './alert-dialog-trigger.svelte';
import Title from './alert-dialog-title.svelte';
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
@@ -35,5 +35,5 @@ export {
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Description as AlertDialogDescription
};
@@ -12,6 +12,6 @@
<AvatarPrimitive.Fallback
bind:ref
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}
/>
@@ -1,36 +1,35 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
import { type VariantProps, tv } from 'tailwind-variants';
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: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
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:
"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",
},
'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'
}
},
defaultVariants: {
variant: "default",
},
variant: 'default'
}
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
@@ -39,7 +38,7 @@
</script>
<svelte:element
this={href ? "a" : "span"}
this={href ? 'a' : 'span'}
bind:this={ref}
data-slot="badge"
{href}
@@ -1,2 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
export { default as Badge } from './badge.svelte';
export { badgeVariants, type BadgeVariant } from './badge.svelte';
@@ -1,9 +1,9 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type Calendar from "./calendar.svelte";
import CalendarMonthSelect from "./calendar-month-select.svelte";
import CalendarYearSelect from "./calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
import type { ComponentProps } from 'svelte';
import type Calendar from './calendar.svelte';
import CalendarMonthSelect from './calendar-month-select.svelte';
import CalendarYearSelect from './calendar-year-select.svelte';
import { DateFormatter, getLocalTimeZone, type DateValue } from '@internationalized/date';
let {
captionLayout,
@@ -14,13 +14,13 @@
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
monthIndex = 0
}: {
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
months: ComponentProps<typeof CalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof CalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
months: ComponentProps<typeof CalendarMonthSelect>['months'];
monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
years: ComponentProps<typeof CalendarYearSelect>['years'];
yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
@@ -29,13 +29,13 @@
function formatYear(date: DateValue) {
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);
}
function formatMonth(date: DateValue) {
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);
}
</script>
@@ -58,15 +58,15 @@
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{#if captionLayout === 'dropdown'}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{:else if captionLayout === 'dropdown-months'}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{:else if captionLayout === 'dropdown-years'}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,7 +12,7 @@
<CalendarPrimitive.Cell
bind:ref
class={cn(
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md",
'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
)}
{...restProps}
@@ -1,7 +1,7 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -13,24 +13,24 @@
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
buttonVariants({ variant: 'ghost' }),
'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-[selected]:bg-primary data-[selected]:text-primary-foreground dark:data-[selected]:hover:bg-accent/50',
// 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
"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
"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])
"[&[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
"dark:hover:text-accent-foreground",
'dark:hover:text-accent-foreground',
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
'focus:relative focus:border-ring focus:ring-ring/50',
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
'[&>span]:text-xs [&>span]:opacity-70',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -9,4 +9,4 @@
}: CalendarPrimitive.GridRowProps = $props();
</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">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -11,6 +11,6 @@
<CalendarPrimitive.Grid
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}
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,7 +12,7 @@
<CalendarPrimitive.HeadCell
bind:ref
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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,7 +12,7 @@
<CalendarPrimitive.Header
bind:ref
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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -11,6 +11,6 @@
<CalendarPrimitive.Heading
bind:ref
class={cn("px-(--cell-size) text-sm font-medium", className)}
class={cn('px-(--cell-size) text-sm font-medium', className)}
{...restProps}
/>
@@ -1,7 +1,7 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
<span
class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
'relative flex rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
className
)}
>
@@ -33,7 +33,7 @@
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
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"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
@@ -1,6 +1,6 @@
<script lang="ts">
import { type WithElementRef, cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { type WithElementRef, cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -10,6 +10,6 @@
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</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?.()}
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,7 +12,7 @@
<div
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}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<nav
{...restProps}
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?.()}
</nav>
@@ -1,14 +1,14 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
variant = 'ghost',
...restProps
}: CalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant;
@@ -23,7 +23,7 @@
bind:ref
class={cn(
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
)}
children={children || Fallback}
@@ -1,14 +1,14 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
variant = 'ghost',
...restProps
}: CalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant;
@@ -23,7 +23,7 @@
bind:ref
class={cn(
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
)}
children={children || Fallback}
@@ -1,7 +1,7 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Calendar as CalendarPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<span
class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
'relative flex rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
className
)}
>
@@ -32,7 +32,7 @@
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
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"
>
{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(
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
// Add fuchsia theme styles for selected date
'[&_[data-selected]]:border-primary [&_[data-selected]]:text-primary [&_[data-selected]]:rounded-md [&_[data-selected]]:bg-fuchsia-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',
// Ensure today indicator works with fuchsia theme
'[&_[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 Cell from "./calendar-cell.svelte";
import Day from "./calendar-day.svelte";
import Grid from "./calendar-grid.svelte";
import Header from "./calendar-header.svelte";
import Months from "./calendar-months.svelte";
import GridRow from "./calendar-grid-row.svelte";
import Heading from "./calendar-heading.svelte";
import GridBody from "./calendar-grid-body.svelte";
import GridHead from "./calendar-grid-head.svelte";
import HeadCell from "./calendar-head-cell.svelte";
import NextButton from "./calendar-next-button.svelte";
import PrevButton from "./calendar-prev-button.svelte";
import MonthSelect from "./calendar-month-select.svelte";
import YearSelect from "./calendar-year-select.svelte";
import Month from "./calendar-month.svelte";
import Nav from "./calendar-nav.svelte";
import Caption from "./calendar-caption.svelte";
import Root from './calendar.svelte';
import Cell from './calendar-cell.svelte';
import Day from './calendar-day.svelte';
import Grid from './calendar-grid.svelte';
import Header from './calendar-header.svelte';
import Months from './calendar-months.svelte';
import GridRow from './calendar-grid-row.svelte';
import Heading from './calendar-heading.svelte';
import GridBody from './calendar-grid-body.svelte';
import GridHead from './calendar-grid-head.svelte';
import HeadCell from './calendar-head-cell.svelte';
import NextButton from './calendar-next-button.svelte';
import PrevButton from './calendar-prev-button.svelte';
import MonthSelect from './calendar-month-select.svelte';
import YearSelect from './calendar-year-select.svelte';
import Month from './calendar-month.svelte';
import Nav from './calendar-nav.svelte';
import Caption from './calendar-caption.svelte';
export {
Day,
@@ -36,5 +36,5 @@ export {
MonthSelect,
Caption,
//
Root as Calendar,
Root as Calendar
};
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="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}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -10,6 +10,6 @@
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</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?.()}
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="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}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
'@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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
class={cn('leading-none font-semibold', className)}
{...restProps}
>
{@render children?.()}
@@ -13,7 +13,7 @@
<div
bind:this={ref}
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}
>
{@render children?.()}
+8 -8
View File
@@ -1,10 +1,10 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
import Root from './card.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Action from './card-action.svelte';
export {
Root,
@@ -21,5 +21,5 @@ export {
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
Action as CardAction
};
@@ -1,8 +1,8 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { Checkbox as CheckboxPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -17,7 +17,7 @@
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
'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
)}
bind:checked
@@ -1,6 +1,6 @@
import Root from "./checkbox.svelte";
import Root from './checkbox.svelte';
export {
Root,
//
Root as Checkbox,
Root as Checkbox
};
@@ -25,7 +25,7 @@
bind:ref
data-slot="dialog-content"
class={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed 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
)}
{...restProps}
@@ -33,7 +33,7 @@
{@render children?.()}
{#if !hideClose}
<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 />
<span class="sr-only">Close</span>
@@ -12,6 +12,6 @@
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn('text-muted-foreground text-sm', className)}
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>
@@ -13,7 +13,7 @@
bind:ref
data-slot="dialog-overlay"
class={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 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
)}
{...restProps}
@@ -1,5 +1,5 @@
<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();
</script>
@@ -1,6 +1,6 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<FormPrimitive.Description
bind:ref
data-slot="form-description"
class={cn("text-muted-foreground text-sm", className)}
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>
@@ -1,8 +1,8 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
import * as FormPrimitive from "formsnap";
import type { FormPathLeaves } from "sveltekit-superforms";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import type { FormPathLeaves } from 'sveltekit-superforms';
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -17,7 +17,7 @@
<FormPrimitive.ElementField {form} {name}>
{#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] })}
</div>
{/snippet}
@@ -1,6 +1,6 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -15,7 +15,7 @@
<FormPrimitive.FieldErrors
bind:ref
class={cn("text-destructive text-sm font-medium", className)}
class={cn('text-sm font-medium text-destructive', className)}
{...restProps}
>
{#snippet children({ errors, errorProps })}
@@ -1,8 +1,8 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap";
import type { FormPath } from "sveltekit-superforms";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import * as FormPrimitive from 'formsnap';
import type { FormPath } from 'sveltekit-superforms';
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -17,12 +17,7 @@
<FormPrimitive.Field {form} {name}>
{#snippet children({ constraints, errors, tainted, value })}
<div
bind:this={ref}
data-slot="form-item"
class={cn("space-y-2", className)}
{...restProps}
>
<div bind:this={ref} data-slot="form-item" class={cn('space-y-2', className)} {...restProps}>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div>
{/snippet}
@@ -1,7 +1,7 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap";
import type { FormPath } from "sveltekit-superforms";
import { cn, type WithoutChild } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import type { FormPath } from 'sveltekit-superforms';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,4 +12,4 @@
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
</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">
import * as FormPrimitive from "formsnap";
import { Label } from "$lib/components/ui/label/index.js";
import { cn, type WithoutChild } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import { Label } from '$lib/components/ui/label/index.js';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -16,7 +16,7 @@
<Label
{...props}
data-slot="form-label"
class={cn("data-[fs-error]:text-destructive", className)}
class={cn('data-[fs-error]:text-destructive', className)}
>
{@render children?.()}
</Label>
@@ -1,6 +1,6 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -11,6 +11,6 @@
<FormPrimitive.Legend
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}
/>
+10 -10
View File
@@ -1,12 +1,12 @@
import * as FormPrimitive from "formsnap";
import Description from "./form-description.svelte";
import Label from "./form-label.svelte";
import FieldErrors from "./form-field-errors.svelte";
import Field from "./form-field.svelte";
import Fieldset from "./form-fieldset.svelte";
import Legend from "./form-legend.svelte";
import ElementField from "./form-element-field.svelte";
import Button from "./form-button.svelte";
import * as FormPrimitive from 'formsnap';
import Description from './form-description.svelte';
import Label from './form-label.svelte';
import FieldErrors from './form-field-errors.svelte';
import Field from './form-field.svelte';
import Fieldset from './form-fieldset.svelte';
import Legend from './form-legend.svelte';
import ElementField from './form-element-field.svelte';
import Button from './form-button.svelte';
const Control = FormPrimitive.Control;
@@ -29,5 +29,5 @@ export {
Fieldset as FormFieldset,
Legend as FormLegend,
ElementField as FormElementField,
Button as FormButton,
Button as FormButton
};
@@ -14,7 +14,7 @@
{@render child({ src: previewState.rootState.src })}
{:else}
<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.Fallback>
@@ -1,7 +1,7 @@
import Root from "./input.svelte";
import Root from './input.svelte';
export {
Root,
//
Root as Input,
Root as Input
};
@@ -1,12 +1,12 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
Omit<HTMLInputAttributes, 'type'> &
({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
@@ -15,19 +15,19 @@
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
'data-slot': dataSlot = 'input',
...restProps
}: Props = $props();
</script>
{#if type === "file"}
{#if type === 'file'}
<input
bind:this={ref}
data-slot={dataSlot}
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",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
'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-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
type="file"
@@ -40,9 +40,9 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
'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-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
{type}
@@ -1,7 +1,7 @@
import Root from "./label.svelte";
import Root from './label.svelte';
export {
Root,
//
Root as Label,
Root as Label
};
@@ -1,6 +1,6 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
'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
)}
{...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 Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Group from './select-group.svelte';
import Label from './select-label.svelte';
import Item from './select-item.svelte';
import Content from './select-content.svelte';
import Trigger from './select-trigger.svelte';
import Separator from './select-separator.svelte';
import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from './select-scroll-up-button.svelte';
import GroupHeading from './select-group-heading.svelte';
const Root = SelectPrimitive.Root;
@@ -33,5 +33,5 @@ export {
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
GroupHeading as SelectGroupHeading
};
@@ -1,8 +1,8 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import { Select as SelectPrimitive } from 'bits-ui';
import SelectScrollUpButton from './select-scroll-up-button.svelte';
import SelectScrollDownButton from './select-scroll-down-button.svelte';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -22,7 +22,7 @@
{sideOffset}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
'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
)}
{...restProps}
@@ -30,7 +30,7 @@
<SelectScrollUpButton />
<SelectPrimitive.Viewport
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?.()}
@@ -1,7 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Select as SelectPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
<SelectPrimitive.GroupHeading
bind:ref
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}
>
{@render children?.()}
@@ -1,5 +1,5 @@
<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();
</script>
@@ -1,7 +1,7 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -18,7 +18,7 @@
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="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}
>
{@render children?.()}
@@ -1,7 +1,7 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<SelectPrimitive.ScrollDownButton
bind:ref
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}
>
<ChevronDownIcon class="size-4" />
@@ -1,7 +1,7 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<SelectPrimitive.ScrollUpButton
bind:ref
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}
>
<ChevronUpIcon class="size-4" />
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { Separator as SeparatorPrimitive } from 'bits-ui';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,6 +13,6 @@
<Separator
bind:ref
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}
/>
@@ -1,16 +1,16 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
import { Select as SelectPrimitive } from 'bits-ui';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
size = "default",
size = 'default',
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
size?: 'sm' | 'default';
} = $props();
</script>
@@ -19,7 +19,7 @@
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"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
)}
{...restProps}
@@ -1,7 +1,7 @@
import Root from "./separator.svelte";
import Root from './separator.svelte';
export {
Root,
//
Root as Separator,
Root as Separator
};
@@ -1,11 +1,11 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
'data-slot': dataSlot = 'separator',
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
@@ -14,7 +14,7 @@
bind:ref
data-slot={dataSlot}
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
)}
{...restProps}
@@ -1,7 +1,7 @@
import Root from "./skeleton.svelte";
import Root from './skeleton.svelte';
export {
Root,
//
Root as Skeleton,
Root as Skeleton
};
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<div
bind:this={ref}
data-slot="skeleton"
class={cn("bg-accent animate-pulse rounded-md", className)}
class={cn('animate-pulse rounded-md bg-accent', className)}
{...restProps}
></div>

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