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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user