refactor: booking flow components with shared utilities and auto-select

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-28 16:30:07 +01:00
co-authored by Sisyphus
parent 7ee77d6bff
commit efbcfbaaf0
5 changed files with 318 additions and 200 deletions
@@ -14,7 +14,7 @@
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
import { SvelteDate } from 'svelte/reactivity';
// Components
import BookingActions from '$lib/components/booking/BookingActions.svelte';
@@ -349,7 +349,7 @@
if (!selectedDate) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey);
delete availableHoursCache[monthKey];
await fetchHoursForMonth(selectedDate);
}
@@ -400,38 +400,6 @@
}
}
// =============== ADD: Lunch Protection ===============
const lunchProtectionStatus = $derived(() => {
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
return new Map();
}
const dateStr = selectedDate.toString();
const dayWorkingHours = workingHours[dateStr];
const dayAvailableHours = availableHours[dateStr];
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) {
return new Map();
}
// Extract existing bookings from the gap between working hours and available hours
const existingBookings = extractBookedSlots(
dayWorkingHours.startTime,
dayWorkingHours.endTime,
dayAvailableHours.slots
);
// Get lunch protection status for all slots
return getLunchProtectionForSlots(
dayWorkingHours.startTime,
dayWorkingHours.endTime,
existingBookings,
getTotalDuration(),
15, // 15 minute slot intervals
false // User journey - requires 1h minimum
);
});
// =============== Working Hours & Available Hours ===============
let workingHours = $state<Record<
string,
@@ -446,22 +414,15 @@
let loadingWorkingHours = $state<boolean>(false);
let loadingAvailableHours = $state<boolean>(false);
const workingHoursCache = new SvelteMap<
let workingHoursCache: Record<
string,
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
>();
> = {};
const availableHoursCache = new SvelteMap<
let availableHoursCache: Record<
string,
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
>();
$effect(() => {
return () => {
workingHoursCache.clear();
availableHoursCache.clear();
};
});
> = {};
// Initialize date boundaries
const today = new SvelteDate();
@@ -480,25 +441,100 @@
let placeholder = $state<CalendarDate>(minDate);
let userNavigatedCalendar = $state(false);
let bookingFlowAutoSelectDone = $state(false);
$effect(() => {
fetchServices();
});
// Preload 3 months on first render to prevent snap-back during navigation
// Track which months are currently being fetched (prevents duplicate requests)
let loadingMonths: Record<string, boolean> = {};
// Preload current + next month on first render; subsequent months fetched individually
let initialLoadDone = $state(false);
$effect(() => {
if (!initialLoadDone) {
fetchHoursRange(placeholder, 3);
// Pre-seed cache for current + next month
for (let i = 0; i < 2; i++) {
let mYear = placeholder.year;
let mMonth = placeholder.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
if (!(key in workingHoursCache)) {
workingHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; startTime: string; endTime: string }>;
availableHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>;
loadingMonths[key] = true;
}
}
fetchHoursRange(placeholder, 2);
initialLoadDone = true;
}
});
// Fetch additional months when navigating beyond preloaded range
// Safety net: fetch silently when navigating to an uncached month
// (uses skipLoadingFlags=true to prevent layout shift / scroll snap)
$effect(() => {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (initialLoadDone && !workingHoursCache.has(monthKey)) {
fetchHoursForMonth(placeholder);
if (initialLoadDone && !(monthKey in workingHoursCache)) {
fetchHoursForMonth(placeholder, true);
}
});
// Data-driven auto-selection: auto-select the first available date when data loads
$effect(() => {
if (workingHours && availableHours && !selectedDate && selectedServices.length > 0 && !userNavigatedCalendar && !bookingFlowAutoSelectDone) {
bookingFlowAutoSelectDone = true;
const currentDate = new SvelteDate();
const maxDateJs = new SvelteDate(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
);
const daysDifference = Math.floor(
(maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toISOString().split('T')[0];
if (workingHours[dateStr]?.isOpen) {
const calDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
nextDate.getDate()
);
if (!isDateUnavailable(calDate)) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1
);
}
return;
}
}
}
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
selectedDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
}
}
});
@@ -550,18 +586,27 @@
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
workingHoursCache.set(key, whMap);
availableHoursCache.set(key, ahMap);
workingHoursCache[key] = whMap;
availableHoursCache[key] = ahMap;
delete loadingMonths[key];
}
workingHours = whMap;
availableHours = ahMap;
if (!selectedDate) {
setDefaultSelectedDate(whMap);
}
// MERGE instead of replace — preserves data from previously loaded months
workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
} catch (error) {
console.error('Failed to fetch hours:', error);
// Clean up loadingMonths for the range
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
delete loadingMonths[key];
}
if (!selectedDate) {
selectedDate = minDate;
}
@@ -571,17 +616,25 @@
}
}
async function fetchHoursForMonth(date: CalendarDate) {
async function fetchHoursForMonth(date: CalendarDate, skipLoadingFlags = false) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
workingHours = workingHoursCache.get(monthKey)!;
availableHours = availableHoursCache.get(monthKey)!;
// Only use cache if the value is truthy (not a pre-seeded null placeholder)
if (workingHoursCache[monthKey] && availableHoursCache[monthKey]) {
// MERGE instead of replace — preserves data from other loaded months
workingHours = { ...workingHours, ...workingHoursCache[monthKey] };
availableHours = { ...availableHours, ...availableHoursCache[monthKey] };
return;
}
loadingWorkingHours = true;
loadingAvailableHours = true;
// Prevent duplicate concurrent requests for the same month
if (loadingMonths[monthKey]) return;
loadingMonths[monthKey] = true;
if (!skipLoadingFlags) {
loadingWorkingHours = true;
loadingAvailableHours = true;
}
try {
const startOfMonth = new CalendarDate(date.year, date.month, 1);
@@ -616,8 +669,8 @@
};
});
workingHoursCache.set(monthKey, workingHoursMap);
workingHours = workingHoursMap;
workingHoursCache[monthKey] = workingHoursMap;
workingHours = { ...workingHours, ...workingHoursMap };
// Fetch available hours
const availableHoursResponse = await fetch(
@@ -640,75 +693,20 @@
};
});
availableHoursCache.set(monthKey, availableHoursMap);
availableHours = availableHoursMap;
if (!selectedDate) {
setDefaultSelectedDate(workingHoursMap);
}
availableHoursCache[monthKey] = availableHoursMap;
availableHours = { ...availableHours, ...availableHoursMap };
} catch (error) {
console.error('Failed to fetch hours:', error);
if (!selectedDate) {
selectedDate = minDate;
}
} finally {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
function setDefaultSelectedDate(
hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }>
) {
const currentDate = new SvelteDate();
const maxDateJs = new SvelteDate(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
);
const daysDifference = Math.floor(
(maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toISOString().split('T')[0];
if (hoursMap[dateStr]?.isOpen) {
const calDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
nextDate.getDate()
);
const duration = getTotalDuration() || 60;
const slots = generateAvailableTimeSlots(duration, calDate);
if (slots.length > 0) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1
);
}
return;
}
delete loadingMonths[monthKey];
if (!skipLoadingFlags) {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
selectedDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
}
}
// =============== Time Slot Generation ===============
@@ -764,12 +762,12 @@
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
let startTotalMinutes = Math.ceil((startHour * 60 + startMinute) / 15) * 15;
const endTotalMinutes = endHour * 60 + endMinute;
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const minimumStartMinutes = currentMinutes + 120;
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
@@ -791,10 +789,7 @@
function generateGroupedTimeSlots(
duration: number,
date: CalendarDate | undefined,
lunchProtection: Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
> = new Map()
lunchProtectionMap: Map<string, { isBlocked: boolean; showWarning: boolean; warningMessage?: string }> = new Map()
): Array<{
type: 'available' | 'unavailable';
startTime: string;
@@ -830,7 +825,7 @@
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const minimumStartMinutes = currentMinutes + 120;
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
@@ -845,7 +840,7 @@
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
const isAvailable =
availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked;
availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked;
if (isAvailable) {
if (currentUnavailableStart !== null) {
@@ -924,6 +919,11 @@
if (!dayHours) return true;
if (!dayHours.isOpen) return true;
// No available hours data for this date = data not loaded = unavailable
if (!availableHours?.[dateStr]) return true;
// API returned empty slots = no availability at all
if (!availableHours[dateStr].slots || availableHours[dateStr].slots.length === 0) return true;
if (selectedServices.length === 0) {
return false;
}
@@ -932,8 +932,9 @@
const availableSlots = generateAvailableTimeSlots(duration, date);
if (availableSlots.length === 0) return true;
const dayAvailableHours = availableHours?.[dateStr];
if (dayAvailableHours?.slots) {
const dayAvailableHours = availableHours[dateStr];
// dayAvailableHours.slots is already checked above, but keep this guard for safety
if (dayAvailableHours.slots) {
const existingBookings = extractBookedSlots(
dayHours.startTime,
dayHours.endTime,
@@ -979,26 +980,55 @@
// Only clear if we're on the date/time selection step
if (currentStep === 2 && selectedDate) {
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey);
delete availableHoursCache[monthKey];
fetchHoursForMonth(selectedDate);
}
// Reset date selection when services change so auto-select can re-run
bookingFlowAutoSelectDone = false;
selectedDate = undefined;
selectedTime = null;
}
// =============== Lunch Protection for Rendering ===============
function getLunchProtectionStatus() {
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
return new Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
>();
}
const dateStr = selectedDate.toString();
const dayWH = workingHours[dateStr];
const dayAH = availableHours[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) return new Map();
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
return getLunchProtectionForSlots(
dayWH.startTime,
dayWH.endTime,
existingBookings,
getTotalDuration(),
15,
false
);
}
// Select a time slot with server-side re-validation
async function selectTimeWithValidation(time: string) {
selectedTime = time;
await refreshAndValidateSlot();
}
// Re-fetch available hours and check if selectedTime is still available
// Re-fetch available hours silently and check if selectedTime is still available
// Uses skipLoadingFlags=true to prevent UI judder (loading spinners hide DatePicker/TimeSlotPicker)
async function refreshAndValidateSlot() {
if (!selectedDate || !selectedTime) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey);
await fetchHoursForMonth(selectedDate);
delete availableHoursCache[monthKey];
await fetchHoursForMonth(selectedDate, true);
const dateStr = selectedDate.toString();
const dayAvailable = availableHours?.[dateStr]?.slots;
@@ -1064,9 +1094,10 @@
// =============== Derived Values ===============
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
const lunchProtectionMap = $derived(getLunchProtectionStatus());
const groupedTimeSlots = $derived(
currentStep === 2 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionStatus())
? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionMap)
: []
);
const formattedSelectedDate = $derived(
@@ -1425,8 +1456,8 @@
selectedTime = null;
}}
onPlaceholderChange={(newPlaceholder) => {
userNavigatedCalendar = true;
placeholder = newPlaceholder;
userNavigatedCalendar = true;
}}
/>
{/if}
@@ -1446,7 +1477,6 @@
onselect={(time) => {
selectTimeWithValidation(time);
}}
lunchProtectionStatus={lunchProtectionStatus()}
/>
{/if}
</Card.Content>
@@ -24,16 +24,28 @@
<div class="flex items-center justify-center p-6">
<Calendar
type="single"
bind:value={date}
bind:placeholder
{isDateUnavailable}
value={date}
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}
// Inline wrapper creates a new function reference on every DatePicker render,
// forcing bits-ui Calendar to re-evaluate availability for each date.
// Without this the stable isDateUnavailable reference may cause stale availability
// after workingHours/availableHours are updated (e.g. after month data loading).
isDateUnavailable={(d) => isDateUnavailable(d)}
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
weekdayFormat="short"
{minValue}
{maxValue}
locale="en-GB"
onValueChange={(v: DateValue | undefined) => {
if (onchange) {
// Guard: bits-ui fires onValueChange(undefined) as an intermediate deselect
// before firing onValueChange(selectedDate). In controlled mode this causes
// the parent to briefly set selectedDate=undefined, which the Calendar then
// receives back, effectively canceling the new selection. Only propagate
// defined values to prevent the first-click-does-nothing issue.
if (onchange && v) {
onchange(v as CalendarDate | undefined);
}
}}
@@ -0,0 +1,44 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator';
import type { LunchProtectionResult } from '$lib/lunchProtection';
interface Props {
selectedDate: string;
selectedTime: string;
endTime: string;
duration: number;
protection: LunchProtectionResult | undefined;
}
let { selectedDate, selectedTime, endTime, duration, protection }: Props = $props();
</script>
<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="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>
<div>
<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'}">
{protection?.warningMessage}
</div>
</div>
</div>
</div>
{/if}
<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}
</div>
<div class="mt-1 text-sm text-emerald-700">
{selectedDate} at {selectedTime}
{' — '}
{endTime}
{' ('}{duration} min)
</div>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { formatTime, type TimeSlotGroup } from '$lib/utils/timeSlots';
import type { LunchProtectionResult } from '$lib/lunchProtection';
interface Props {
slots: TimeSlotGroup[];
selectedTime: string | null;
duration: number;
protection: Map<string, LunchProtectionResult>;
onSelect: (time: string) => void;
}
let { slots, selectedTime, duration, protection, onSelect }: Props = $props();
</script>
{#if slots.length > 0}
<div class="grid gap-2">
{#each slots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
{#if slot.type === 'available'}
{@const p = protection.get(slot.startTime)}
{#if p?.isBlocked}
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
title={p.warningMessage || 'Lunch protection'}
>
{formatTime(slot.startTime)}
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span>
<span class="ml-auto text-xs text-gray-400">{duration} min</span>
</Button>
{:else}
<Button
variant="outline"
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' : '')
}`}
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>
</span>
{/if}
{formatTime(slot.startTime)}
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span>
<span class="ml-auto text-xs text-gray-400">{duration} min</span>
</Button>
{/if}
{:else}
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
>
{formatTime(slot.startTime)}
<span class="text-gray-500 ml-2">- {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>
{/if}
@@ -7,8 +7,7 @@
groupedTimeSlots = [],
selectedTime = null,
formattedDate,
onselect,
lunchProtectionStatus = new Map()
onselect
}: {
date: CalendarDate | undefined;
groupedTimeSlots?: Array<{
@@ -20,10 +19,6 @@
selectedTime?: string | null;
formattedDate?: string;
onselect?: (time: string) => void;
lunchProtectionStatus?: Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
>;
} = $props();
function formatTime(time: string): string {
@@ -54,52 +49,19 @@
<div class="grid gap-2">
{#each groupedTimeSlots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
{#if slot.type === 'available'}
{@const protection = lunchProtectionStatus.get(slot.startTime)}
{#if protection?.isBlocked}
<!-- Blocked by lunch protection -->
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
title={protection.warningMessage || 'Lunch protection'}
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{:else}
<!-- Available slot (possibly with warning for admin) -->
<Button
variant="outline"
onclick={() => {
if (onselect) {
onselect(slot.startTime);
}
}}
class={`w-full hover:bg-fuchsia-50 ${
slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''
} ${protection?.showWarning ? 'border-amber-400 bg-amber-50' : ''}`}
title={protection?.warningMessage}
>
{#if protection?.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>
</span>
{/if}
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
<!-- Available slot -->
<Button
variant="outline"
onclick={() => {
if (onselect) {
onselect(slot.startTime);
}
}}
class={`w-full hover:bg-fuchsia-50 ${slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''}`}
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{:else}
<!-- Unavailable slot (already booked) -->
<Button