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 { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
if (!selectedDate) return;
|
if (!selectedDate) return;
|
||||||
|
|
||||||
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
||||||
availableHoursCache.delete(monthKey);
|
delete availableHoursCache[monthKey];
|
||||||
await fetchHoursForMonth(selectedDate);
|
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 ===============
|
// =============== Working Hours & Available Hours ===============
|
||||||
let workingHours = $state<Record<
|
let workingHours = $state<Record<
|
||||||
string,
|
string,
|
||||||
@@ -446,22 +414,15 @@
|
|||||||
let loadingWorkingHours = $state<boolean>(false);
|
let loadingWorkingHours = $state<boolean>(false);
|
||||||
let loadingAvailableHours = $state<boolean>(false);
|
let loadingAvailableHours = $state<boolean>(false);
|
||||||
|
|
||||||
const workingHoursCache = new SvelteMap<
|
let workingHoursCache: Record<
|
||||||
string,
|
string,
|
||||||
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||||
>();
|
> = {};
|
||||||
|
|
||||||
const availableHoursCache = new SvelteMap<
|
let availableHoursCache: Record<
|
||||||
string,
|
string,
|
||||||
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
|
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
|
||||||
>();
|
> = {};
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
return () => {
|
|
||||||
workingHoursCache.clear();
|
|
||||||
availableHoursCache.clear();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize date boundaries
|
// Initialize date boundaries
|
||||||
const today = new SvelteDate();
|
const today = new SvelteDate();
|
||||||
@@ -480,25 +441,100 @@
|
|||||||
|
|
||||||
let placeholder = $state<CalendarDate>(minDate);
|
let placeholder = $state<CalendarDate>(minDate);
|
||||||
let userNavigatedCalendar = $state(false);
|
let userNavigatedCalendar = $state(false);
|
||||||
|
let bookingFlowAutoSelectDone = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
fetchServices();
|
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);
|
let initialLoadDone = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!initialLoadDone) {
|
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;
|
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(() => {
|
$effect(() => {
|
||||||
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||||
if (initialLoadDone && !workingHoursCache.has(monthKey)) {
|
if (initialLoadDone && !(monthKey in workingHoursCache)) {
|
||||||
fetchHoursForMonth(placeholder);
|
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++;
|
mYear++;
|
||||||
}
|
}
|
||||||
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
||||||
workingHoursCache.set(key, whMap);
|
workingHoursCache[key] = whMap;
|
||||||
availableHoursCache.set(key, ahMap);
|
availableHoursCache[key] = ahMap;
|
||||||
|
delete loadingMonths[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
workingHours = whMap;
|
// MERGE instead of replace — preserves data from previously loaded months
|
||||||
availableHours = ahMap;
|
workingHours = { ...workingHours, ...whMap };
|
||||||
|
availableHours = { ...availableHours, ...ahMap };
|
||||||
if (!selectedDate) {
|
|
||||||
setDefaultSelectedDate(whMap);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch hours:', 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) {
|
if (!selectedDate) {
|
||||||
selectedDate = minDate;
|
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')}`;
|
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||||
|
|
||||||
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
|
// Only use cache if the value is truthy (not a pre-seeded null placeholder)
|
||||||
workingHours = workingHoursCache.get(monthKey)!;
|
if (workingHoursCache[monthKey] && availableHoursCache[monthKey]) {
|
||||||
availableHours = availableHoursCache.get(monthKey)!;
|
// MERGE instead of replace — preserves data from other loaded months
|
||||||
|
workingHours = { ...workingHours, ...workingHoursCache[monthKey] };
|
||||||
|
availableHours = { ...availableHours, ...availableHoursCache[monthKey] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadingWorkingHours = true;
|
// Prevent duplicate concurrent requests for the same month
|
||||||
loadingAvailableHours = true;
|
if (loadingMonths[monthKey]) return;
|
||||||
|
loadingMonths[monthKey] = true;
|
||||||
|
|
||||||
|
if (!skipLoadingFlags) {
|
||||||
|
loadingWorkingHours = true;
|
||||||
|
loadingAvailableHours = true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
||||||
@@ -616,8 +669,8 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
workingHoursCache.set(monthKey, workingHoursMap);
|
workingHoursCache[monthKey] = workingHoursMap;
|
||||||
workingHours = workingHoursMap;
|
workingHours = { ...workingHours, ...workingHoursMap };
|
||||||
|
|
||||||
// Fetch available hours
|
// Fetch available hours
|
||||||
const availableHoursResponse = await fetch(
|
const availableHoursResponse = await fetch(
|
||||||
@@ -640,75 +693,20 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
availableHoursCache.set(monthKey, availableHoursMap);
|
availableHoursCache[monthKey] = availableHoursMap;
|
||||||
availableHours = availableHoursMap;
|
availableHours = { ...availableHours, ...availableHoursMap };
|
||||||
|
|
||||||
if (!selectedDate) {
|
|
||||||
setDefaultSelectedDate(workingHoursMap);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch hours:', error);
|
console.error('Failed to fetch hours:', error);
|
||||||
if (!selectedDate) {
|
if (!selectedDate) {
|
||||||
selectedDate = minDate;
|
selectedDate = minDate;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loadingWorkingHours = false;
|
delete loadingMonths[monthKey];
|
||||||
loadingAvailableHours = false;
|
if (!skipLoadingFlags) {
|
||||||
}
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ===============
|
// =============== Time Slot Generation ===============
|
||||||
@@ -764,12 +762,12 @@
|
|||||||
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
|
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
|
||||||
const [endHour, endMinute] = slot.endTime.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;
|
const endTotalMinutes = endHour * 60 + endMinute;
|
||||||
|
|
||||||
if (isToday) {
|
if (isToday) {
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
const minimumStartMinutes = currentMinutes + 120;
|
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
|
||||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,10 +789,7 @@
|
|||||||
function generateGroupedTimeSlots(
|
function generateGroupedTimeSlots(
|
||||||
duration: number,
|
duration: number,
|
||||||
date: CalendarDate | undefined,
|
date: CalendarDate | undefined,
|
||||||
lunchProtection: Map<
|
lunchProtectionMap: Map<string, { isBlocked: boolean; showWarning: boolean; warningMessage?: string }> = new Map()
|
||||||
string,
|
|
||||||
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
|
|
||||||
> = new Map()
|
|
||||||
): Array<{
|
): Array<{
|
||||||
type: 'available' | 'unavailable';
|
type: 'available' | 'unavailable';
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@@ -830,7 +825,7 @@
|
|||||||
|
|
||||||
if (isToday) {
|
if (isToday) {
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
const minimumStartMinutes = currentMinutes + 120;
|
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
|
||||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,7 +840,7 @@
|
|||||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
|
||||||
const isAvailable =
|
const isAvailable =
|
||||||
availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked;
|
availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked;
|
||||||
|
|
||||||
if (isAvailable) {
|
if (isAvailable) {
|
||||||
if (currentUnavailableStart !== null) {
|
if (currentUnavailableStart !== null) {
|
||||||
@@ -924,6 +919,11 @@
|
|||||||
if (!dayHours) return true;
|
if (!dayHours) return true;
|
||||||
if (!dayHours.isOpen) 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) {
|
if (selectedServices.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -932,8 +932,9 @@
|
|||||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||||
if (availableSlots.length === 0) return true;
|
if (availableSlots.length === 0) return true;
|
||||||
|
|
||||||
const dayAvailableHours = availableHours?.[dateStr];
|
const dayAvailableHours = availableHours[dateStr];
|
||||||
if (dayAvailableHours?.slots) {
|
// dayAvailableHours.slots is already checked above, but keep this guard for safety
|
||||||
|
if (dayAvailableHours.slots) {
|
||||||
const existingBookings = extractBookedSlots(
|
const existingBookings = extractBookedSlots(
|
||||||
dayHours.startTime,
|
dayHours.startTime,
|
||||||
dayHours.endTime,
|
dayHours.endTime,
|
||||||
@@ -979,26 +980,55 @@
|
|||||||
// Only clear if we're on the date/time selection step
|
// Only clear if we're on the date/time selection step
|
||||||
if (currentStep === 2 && selectedDate) {
|
if (currentStep === 2 && selectedDate) {
|
||||||
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
||||||
availableHoursCache.delete(monthKey);
|
delete availableHoursCache[monthKey];
|
||||||
fetchHoursForMonth(selectedDate);
|
fetchHoursForMonth(selectedDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset date selection when services change so auto-select can re-run
|
||||||
|
bookingFlowAutoSelectDone = false;
|
||||||
|
selectedDate = undefined;
|
||||||
selectedTime = null;
|
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
|
// Select a time slot with server-side re-validation
|
||||||
async function selectTimeWithValidation(time: string) {
|
async function selectTimeWithValidation(time: string) {
|
||||||
selectedTime = time;
|
selectedTime = time;
|
||||||
await refreshAndValidateSlot();
|
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() {
|
async function refreshAndValidateSlot() {
|
||||||
if (!selectedDate || !selectedTime) return;
|
if (!selectedDate || !selectedTime) return;
|
||||||
|
|
||||||
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
||||||
availableHoursCache.delete(monthKey);
|
delete availableHoursCache[monthKey];
|
||||||
await fetchHoursForMonth(selectedDate);
|
await fetchHoursForMonth(selectedDate, true);
|
||||||
|
|
||||||
const dateStr = selectedDate.toString();
|
const dateStr = selectedDate.toString();
|
||||||
const dayAvailable = availableHours?.[dateStr]?.slots;
|
const dayAvailable = availableHours?.[dateStr]?.slots;
|
||||||
@@ -1064,9 +1094,10 @@
|
|||||||
|
|
||||||
// =============== Derived Values ===============
|
// =============== Derived Values ===============
|
||||||
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
||||||
|
const lunchProtectionMap = $derived(getLunchProtectionStatus());
|
||||||
const groupedTimeSlots = $derived(
|
const groupedTimeSlots = $derived(
|
||||||
currentStep === 2 && selectedServices.length > 0 && selectedDate
|
currentStep === 2 && selectedServices.length > 0 && selectedDate
|
||||||
? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionStatus())
|
? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionMap)
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
const formattedSelectedDate = $derived(
|
const formattedSelectedDate = $derived(
|
||||||
@@ -1425,8 +1456,8 @@
|
|||||||
selectedTime = null;
|
selectedTime = null;
|
||||||
}}
|
}}
|
||||||
onPlaceholderChange={(newPlaceholder) => {
|
onPlaceholderChange={(newPlaceholder) => {
|
||||||
userNavigatedCalendar = true;
|
|
||||||
placeholder = newPlaceholder;
|
placeholder = newPlaceholder;
|
||||||
|
userNavigatedCalendar = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1446,7 +1477,6 @@
|
|||||||
onselect={(time) => {
|
onselect={(time) => {
|
||||||
selectTimeWithValidation(time);
|
selectTimeWithValidation(time);
|
||||||
}}
|
}}
|
||||||
lunchProtectionStatus={lunchProtectionStatus()}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
|||||||
@@ -24,16 +24,28 @@
|
|||||||
<div class="flex items-center justify-center p-6">
|
<div class="flex items-center justify-center p-6">
|
||||||
<Calendar
|
<Calendar
|
||||||
type="single"
|
type="single"
|
||||||
bind:value={date}
|
value={date}
|
||||||
bind:placeholder
|
placeholder={placeholder}
|
||||||
{isDateUnavailable}
|
// 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"
|
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"
|
weekdayFormat="short"
|
||||||
{minValue}
|
{minValue}
|
||||||
{maxValue}
|
{maxValue}
|
||||||
locale="en-GB"
|
locale="en-GB"
|
||||||
onValueChange={(v: DateValue | undefined) => {
|
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);
|
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 = [],
|
groupedTimeSlots = [],
|
||||||
selectedTime = null,
|
selectedTime = null,
|
||||||
formattedDate,
|
formattedDate,
|
||||||
onselect,
|
onselect
|
||||||
lunchProtectionStatus = new Map()
|
|
||||||
}: {
|
}: {
|
||||||
date: CalendarDate | undefined;
|
date: CalendarDate | undefined;
|
||||||
groupedTimeSlots?: Array<{
|
groupedTimeSlots?: Array<{
|
||||||
@@ -20,10 +19,6 @@
|
|||||||
selectedTime?: string | null;
|
selectedTime?: string | null;
|
||||||
formattedDate?: string;
|
formattedDate?: string;
|
||||||
onselect?: (time: string) => void;
|
onselect?: (time: string) => void;
|
||||||
lunchProtectionStatus?: Map<
|
|
||||||
string,
|
|
||||||
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
|
|
||||||
>;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function formatTime(time: string): string {
|
function formatTime(time: string): string {
|
||||||
@@ -54,52 +49,19 @@
|
|||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
{#each groupedTimeSlots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
|
{#each groupedTimeSlots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
|
||||||
{#if slot.type === 'available'}
|
{#if slot.type === 'available'}
|
||||||
{@const protection = lunchProtectionStatus.get(slot.startTime)}
|
<!-- Available slot -->
|
||||||
{#if protection?.isBlocked}
|
<Button
|
||||||
<!-- Blocked by lunch protection -->
|
variant="outline"
|
||||||
<Button
|
onclick={() => {
|
||||||
variant="outline"
|
if (onselect) {
|
||||||
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
|
onselect(slot.startTime);
|
||||||
disabled
|
}
|
||||||
title={protection.warningMessage || 'Lunch protection'}
|
}}
|
||||||
>
|
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>
|
{formatTime(slot.startTime)}
|
||||||
</Button>
|
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||||
{:else}
|
</Button>
|
||||||
<!-- 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}
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Unavailable slot (already booked) -->
|
<!-- Unavailable slot (already booked) -->
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user