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:
@@ -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?.()}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user