- new_card_token uses explicit newCardToken ?? verificationToken precedence on every charge surface (BookingFlow, UserPaymentModal, TipPayment, PaymentModal, TillPurchases, account gift-card buy); dead verification_code/consent fields + ScaFallbackConsentDialog removed from payment flows
- mock mints cnon:sca-... tokenize-results and tokenizeWithVerification returns verificationToken:null for new cards (real-SDK parity so save-card works in dev)
- UserPaymentModal infinite /payment-methods fetch loop guarded; formatCurrency(totalPaid) no longer 100x too small
- delete-account dialog collects current_password + fresh 2FA code; admin 'Begin appointment'/'Complete' wired to /admin/bookings/{id}/progress
- mobile: 44px touch targets, active: feedback, TimeSlotPicker 50dvh, dialog close sizing, .no-scrollbar utility, CSP meta, receipt fields escaped
- vitest: policy.ts cross-check + ScaFallbackConsentDialog component tests (svelte project via happy-dom)
1310 lines
44 KiB
Svelte
1310 lines
44 KiB
Svelte
<script lang="ts">
|
|
import { apiFetch } from '$lib/utils/api';
|
|
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
|
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
|
import { toast } from 'svelte-sonner';
|
|
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
|
import * as Modal from '$lib/components/ui/dialog';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import * as Textarea from '$lib/components/ui/textarea';
|
|
import * as Label from '$lib/components/ui/label';
|
|
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
|
import type { Booking, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
|
import {
|
|
extractBookedSlots,
|
|
getLunchProtectionForSlots,
|
|
timeToMinutes
|
|
} from '$lib/lunchProtection';
|
|
import {
|
|
formatLocalDateTime,
|
|
getLondonTodayCalendarDate,
|
|
parseWallClockDate
|
|
} from '$lib/utils/timeSlots';
|
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
|
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
|
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
booking: Booking;
|
|
onSubmitted: () => void;
|
|
}
|
|
|
|
let { open = $bindable(), booking, onSubmitted }: Props = $props();
|
|
|
|
// ─── Mode ───────────────────────────────────────────────
|
|
type EditMode = 'select' | 'time' | 'services' | 'both-services' | 'both-time';
|
|
let editMode = $state<EditMode>('select');
|
|
|
|
// ─── Service selection ──────────────────────────────────
|
|
let selectedServices = $state<Service[]>([]);
|
|
let availableServices = $state<Service[]>([]);
|
|
let loadingServices = $state(false);
|
|
|
|
// ─── Time selection ─────────────────────────────────────
|
|
let newDate = $state<CalendarDate | undefined>(undefined);
|
|
let newTime = $state('');
|
|
let notes = $state('');
|
|
let originalNotes = $state('');
|
|
let submitting = $state(false);
|
|
|
|
// ─── Working / available hours with caching ─────────────
|
|
let workingHours = $state<Record<
|
|
string,
|
|
{ isOpen: boolean; startTime: string; endTime: string }
|
|
> | null>(null);
|
|
let availableHours = $state<Record<
|
|
string,
|
|
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
|
> | null>(null);
|
|
let loadingHours = $state(false);
|
|
|
|
const workingHoursCache = new SvelteMap<
|
|
string,
|
|
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
|
>();
|
|
const availableHoursCache = new SvelteMap<
|
|
string,
|
|
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
|
|
>();
|
|
let userNavigatedCalendar = $state(false);
|
|
let editRequestAutoSelectDone = $state(false);
|
|
// ─── Date constants ─────────────────────────────────────
|
|
const todayCalendarDate = getLondonTodayCalendarDate();
|
|
const minDate = todayCalendarDate;
|
|
const maxDate = new SvelteDate(
|
|
todayCalendarDate.year,
|
|
todayCalendarDate.month - 1,
|
|
todayCalendarDate.day
|
|
);
|
|
maxDate.setMonth(todayCalendarDate.month - 1 + 6);
|
|
const maxCalendarDate = new CalendarDate(
|
|
maxDate.getFullYear(),
|
|
maxDate.getMonth() + 1,
|
|
maxDate.getDate()
|
|
);
|
|
let placeholderDate = $state<CalendarDate>(minDate);
|
|
|
|
const hoursUntilAppointment = $derived(
|
|
(parseWallClockDate(booking.start_time).getTime() - new Date().getTime()) / (1000 * 60 * 60)
|
|
);
|
|
const hasPayments = $derived((booking.amount_paid ?? 0) > 0);
|
|
const noticePeriodBlocked = $derived(
|
|
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
|
|
);
|
|
const noticePeriodWarning = $derived(
|
|
!hasPayments && hoursUntilAppointment < 24 && hoursUntilAppointment >= 0
|
|
);
|
|
const noticeBlockedMessage = $derived(
|
|
hasPayments
|
|
? 'This booking has payments and is too close to the original appointment to reschedule online.'
|
|
: 'This booking is too close to the original appointment time to reschedule online.'
|
|
);
|
|
|
|
const discountTotal = $derived(
|
|
booking.discounts?.reduce((sum, d) => sum + d.discount_amount, 0) ?? 0
|
|
);
|
|
|
|
const bookingTotalDuration = $derived(
|
|
booking.services?.reduce(
|
|
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
|
|
0
|
|
) || 0
|
|
);
|
|
|
|
const selectedServicesDuration = $derived(
|
|
selectedServices.reduce((sum, s) => sum + (s.duration_minutes ?? 0), 0)
|
|
);
|
|
|
|
const hasOverrides = $derived(
|
|
booking?.services?.some(
|
|
(s) => s.override_price != null || s.override_duration_minutes != null
|
|
) ?? false
|
|
);
|
|
|
|
const slotDuration = $derived(
|
|
editMode === 'time' ? bookingTotalDuration : selectedServicesDuration
|
|
);
|
|
|
|
const canSubmit = $derived(
|
|
submitting === false &&
|
|
((editMode === 'time' && !!newDate && newTime.length >= 4) ||
|
|
(editMode === 'services' && selectedServices.length > 0) ||
|
|
(editMode === 'both-time' && !!newDate && newTime.length >= 4))
|
|
);
|
|
|
|
const notesChanged = $derived(notes.trim() !== originalNotes.trim());
|
|
|
|
const modalTitle = $derived(() => {
|
|
switch (editMode) {
|
|
case 'select':
|
|
return 'Edit/Reschedule';
|
|
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) {
|
|
return new Map();
|
|
}
|
|
|
|
const dateStr = newDate.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,
|
|
slotDuration,
|
|
15,
|
|
false
|
|
);
|
|
});
|
|
|
|
// ─── Helpers ────────────────────────────────────────────
|
|
function formatTime(time: string): string {
|
|
const parts = time.split(':').map(Number);
|
|
const hours = parts[0];
|
|
const minutes = parts.length > 1 ? parts[1] : 0;
|
|
if (hours === 12 && minutes === 0) return 'Noon';
|
|
if (hours === 0 && minutes === 0) return 'Midnight';
|
|
const period = hours >= 12 ? 'PM' : 'AM';
|
|
const displayHours = hours % 12 || 12;
|
|
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
|
}
|
|
|
|
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
|
const [hours, minutes] = startTime.split(':').map(Number);
|
|
const total = hours * 60 + minutes + durationMinutes;
|
|
const h = Math.floor(total / 60);
|
|
const m = total % 60;
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
}
|
|
|
|
function calculatePreviousTime(time: string): string {
|
|
const [h, m] = time.split(':').map(Number);
|
|
const total = h * 60 + m - 15;
|
|
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
function generateAvailableTimeSlots(duration: number, date: CalendarDate): string[] {
|
|
if (!workingHours || !availableHours) return [];
|
|
const dateStr = date.toString();
|
|
const dayWH = workingHours[dateStr];
|
|
const dayAH = availableHours[dateStr];
|
|
if (!dayWH || !dayWH.isOpen || !dayAH || !dayAH.slots) return [];
|
|
|
|
const slots: string[] = [];
|
|
const todayCal = getLondonTodayCalendarDate();
|
|
const isToday = date.compare(todayCal) === 0;
|
|
|
|
for (const slot of dayAH.slots) {
|
|
const [sh, sm] = slot.startTime.split(':').map(Number);
|
|
const [eh, em] = slot.endTime.split(':').map(Number);
|
|
// Round up to next 15-min boundary so generated times align with
|
|
// the 15-min grid from working hours start used in generateGroupedTimeSlots
|
|
let startMin = Math.ceil((sh * 60 + sm) / 15) * 15;
|
|
const endMin = eh * 60 + em;
|
|
|
|
if (isToday) {
|
|
const now = new Date();
|
|
const londonTime = now.toLocaleTimeString('en-GB', {
|
|
timeZone: 'Europe/London',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
const [londonHours, londonMinutes] = londonTime.split(':').map(Number);
|
|
const currentMin = londonHours * 60 + londonMinutes;
|
|
startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15);
|
|
}
|
|
|
|
for (let m = startMin; m < endMin; m += 15) {
|
|
if (m + duration <= endMin) {
|
|
slots.push(
|
|
`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
function generateGroupedTimeSlots(
|
|
duration: number,
|
|
date: CalendarDate,
|
|
lunchProtectionMap: Map<
|
|
string,
|
|
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
|
|
> = new Map()
|
|
): Array<{
|
|
type: 'available' | 'unavailable';
|
|
startTime: string;
|
|
endTime: string;
|
|
isGrouped?: boolean;
|
|
}> {
|
|
if (!workingHours) return [];
|
|
const dateStr = date.toString();
|
|
const dayWH = workingHours[dateStr];
|
|
if (!dayWH || !dayWH.isOpen) return [];
|
|
|
|
const grouped: Array<{
|
|
type: 'available' | 'unavailable';
|
|
startTime: string;
|
|
endTime: string;
|
|
isGrouped?: boolean;
|
|
}> = [];
|
|
const [sh, sm] = dayWH.startTime.split(':').map(Number);
|
|
const [eh, em] = dayWH.endTime.split(':').map(Number);
|
|
let startMin = sh * 60 + sm;
|
|
const endMin = eh * 60 + em;
|
|
|
|
const todayCal = getLondonTodayCalendarDate();
|
|
const isToday = date.compare(todayCal) === 0;
|
|
if (isToday) {
|
|
const now = new Date();
|
|
const londonTime = now.toLocaleTimeString('en-GB', {
|
|
timeZone: 'Europe/London',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
const [londonHours, londonMinutes] = londonTime.split(':').map(Number);
|
|
const currentMin = londonHours * 60 + londonMinutes;
|
|
startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15);
|
|
}
|
|
|
|
const availableSlots = generateAvailableTimeSlots(duration, date);
|
|
let currentUnavailableStart: string | null = null;
|
|
let lastAvailableEnd: string | null = null;
|
|
|
|
for (let m = startMin; m < endMin; m += 15) {
|
|
const timeStr = `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
|
const isAvailable =
|
|
availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked;
|
|
|
|
if (isAvailable) {
|
|
if (currentUnavailableStart !== null) {
|
|
const groupEnd = calculatePreviousTime(timeStr);
|
|
const unavailableStartTime = lastAvailableEnd || currentUnavailableStart;
|
|
if (
|
|
unavailableStartTime &&
|
|
timeToMinutes(unavailableStartTime) < timeToMinutes(groupEnd)
|
|
) {
|
|
grouped.push({
|
|
type: 'unavailable',
|
|
startTime: unavailableStartTime,
|
|
endTime: groupEnd,
|
|
isGrouped: true
|
|
});
|
|
}
|
|
currentUnavailableStart = null;
|
|
}
|
|
const slotEnd = calculateEndTime(timeStr, duration);
|
|
lastAvailableEnd = slotEnd;
|
|
grouped.push({ type: 'available', startTime: timeStr, endTime: slotEnd });
|
|
} else {
|
|
if (currentUnavailableStart === null) {
|
|
currentUnavailableStart = timeStr;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (currentUnavailableStart !== null) {
|
|
const lastAvail = grouped.filter((s) => s.type === 'available').pop();
|
|
const lastAvailEnd = lastAvail ? timeToMinutes(lastAvail.endTime) : 0;
|
|
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
|
|
if (unavailableStartMinutes < endMin && lastAvailEnd < endMin) {
|
|
grouped.push({
|
|
type: 'unavailable',
|
|
startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart,
|
|
endTime: dayWH.endTime,
|
|
isGrouped: true
|
|
});
|
|
}
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
function isDateUnavailable(date: DateValue): boolean {
|
|
const d = date as CalendarDate;
|
|
if (d.compare(minDate) < 0 || d.compare(maxCalendarDate) > 0) return true;
|
|
if (!workingHours) return true;
|
|
const dateStr = d.toString();
|
|
const dayHours = workingHours[dateStr];
|
|
if (!dayHours || !dayHours.isOpen) return true;
|
|
if (slotDuration === 0) return false;
|
|
const slots = generateAvailableTimeSlots(slotDuration, d);
|
|
if (slots.length === 0) return true;
|
|
|
|
const dayAH = availableHours?.[dateStr];
|
|
if (dayAH?.slots) {
|
|
const existingBookings = extractBookedSlots(
|
|
dayHours.startTime,
|
|
dayHours.endTime,
|
|
dayAH.slots
|
|
);
|
|
const lunchProtection = getLunchProtectionForSlots(
|
|
dayHours.startTime,
|
|
dayHours.endTime,
|
|
existingBookings,
|
|
slotDuration,
|
|
15,
|
|
false
|
|
);
|
|
const validSlots = slots.filter((t) => !lunchProtection.get(t)?.isBlocked);
|
|
if (validSlots.length === 0) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function initializeServices(): void {
|
|
selectedServices = (booking.services || []).map((s) => ({
|
|
id: s.service_id,
|
|
name: s.service_name || '',
|
|
description: s.service_description || '',
|
|
price: s.override_price ?? s.price ?? 0,
|
|
duration_minutes: s.override_duration_minutes ?? s.duration_minutes ?? 0,
|
|
patch_test_duration_hours: 0,
|
|
minimum_age_required: 0
|
|
}));
|
|
}
|
|
|
|
function initializeHours(): void {
|
|
workingHours = null;
|
|
availableHours = null;
|
|
loadingHours = false;
|
|
workingHoursCache.clear();
|
|
availableHoursCache.clear();
|
|
userNavigatedCalendar = false;
|
|
}
|
|
|
|
$effect(() => {
|
|
if (open) {
|
|
initializeServices();
|
|
editMode = 'select';
|
|
newDate = undefined;
|
|
newTime = '';
|
|
originalNotes = booking.notes || '';
|
|
notes = originalNotes;
|
|
submitting = false;
|
|
initializeHours();
|
|
editRequestAutoSelectDone = false;
|
|
placeholderDate = minDate;
|
|
fetchHoursRange(minDate, 3);
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (editMode === 'services' || editMode === 'both-services') {
|
|
fetchAvailableServices();
|
|
}
|
|
});
|
|
|
|
let servicesHoursFetched = $state(false);
|
|
$effect(() => {
|
|
if (editMode === 'services' && !servicesHoursFetched) {
|
|
servicesHoursFetched = true;
|
|
const bookingDate = parseWallClockDate(booking.start_time);
|
|
const calDate = new CalendarDate(
|
|
bookingDate.getFullYear(),
|
|
bookingDate.getMonth() + 1,
|
|
bookingDate.getDate()
|
|
);
|
|
fetchHoursForMonth(calDate);
|
|
}
|
|
if (editMode !== 'services') {
|
|
servicesHoursFetched = false;
|
|
}
|
|
});
|
|
|
|
// ─── Auto-select ────────────────────────────────────────
|
|
$effect(() => {
|
|
if (
|
|
!workingHours ||
|
|
!availableHours ||
|
|
newDate ||
|
|
userNavigatedCalendar ||
|
|
editRequestAutoSelectDone
|
|
)
|
|
return;
|
|
editRequestAutoSelectDone = true;
|
|
|
|
const currentDate = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
|
const maxDateJs = new Date(
|
|
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 = 0; i <= daysToCheck; i++) {
|
|
const nextDate = new SvelteDate(currentDate);
|
|
nextDate.setDate(currentDate.getDate() + i);
|
|
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
|
|
|
if (workingHours[dateStr]?.isOpen) {
|
|
const calDate = new CalendarDate(
|
|
nextDate.getFullYear(),
|
|
nextDate.getMonth() + 1,
|
|
nextDate.getDate()
|
|
);
|
|
if (!isDateUnavailable(calDate)) {
|
|
newDate = calDate;
|
|
if (!userNavigatedCalendar) {
|
|
placeholderDate = new CalendarDate(nextDate.getFullYear(), nextDate.getMonth() + 1, 1);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const tomorrowCal = getLondonTodayCalendarDate();
|
|
newDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, tomorrowCal.day + 1);
|
|
if (!userNavigatedCalendar) {
|
|
placeholderDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
|
|
}
|
|
});
|
|
|
|
// Clear selection when navigating to a month that doesn't contain the selected date.
|
|
// Runs AFTER all synchronous state changes settle, so clicking a date in a different
|
|
// month (fires both onPlaceholderChange and onValueChange) keeps the new selection,
|
|
// while clicking prev/next arrows without picking a date clears it.
|
|
$effect(() => {
|
|
if (
|
|
newDate &&
|
|
placeholderDate &&
|
|
(newDate.month !== placeholderDate.month || newDate.year !== placeholderDate.year)
|
|
) {
|
|
newDate = undefined;
|
|
newTime = '';
|
|
}
|
|
});
|
|
|
|
// ─── API calls ──────────────────────────────────────────
|
|
async function fetchHoursRange(startDate: CalendarDate, months: number) {
|
|
loadingHours = true;
|
|
try {
|
|
// Calculate end month manually (CalendarDate is immutable)
|
|
let endYear = startDate.year;
|
|
let endMonth = startDate.month + months - 1;
|
|
while (endMonth > 12) {
|
|
endMonth -= 12;
|
|
endYear++;
|
|
}
|
|
const endMonthDate = new CalendarDate(endYear, endMonth, 1);
|
|
const daysInEndMonth = endMonthDate.calendar.getDaysInMonth(endMonthDate);
|
|
|
|
const startStr = startDate.toString();
|
|
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
|
|
|
|
const [whRes, ahRes] = await Promise.all([
|
|
apiFetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
|
|
apiFetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`)
|
|
]);
|
|
|
|
if (whRes.ok && ahRes.ok) {
|
|
const whData: WorkingHoursDay[] = await whRes.json();
|
|
const ahData: AvailableHoursDay[] = await ahRes.json();
|
|
|
|
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
|
whData.forEach((d) => {
|
|
whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime };
|
|
});
|
|
|
|
const ahMap: Record<
|
|
string,
|
|
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
|
> = {};
|
|
ahData.forEach((d) => {
|
|
ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots };
|
|
});
|
|
|
|
// Cache by month key
|
|
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')}`;
|
|
workingHoursCache.set(key, whMap);
|
|
availableHoursCache.set(key, ahMap);
|
|
}
|
|
|
|
// MERGE instead of replace — preserves data from previously loaded months
|
|
workingHours = { ...(workingHours || {}), ...whMap };
|
|
availableHours = { ...(availableHours || {}), ...ahMap };
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch hours:', err);
|
|
} finally {
|
|
loadingHours = false;
|
|
}
|
|
}
|
|
|
|
async function fetchHoursForMonth(date: CalendarDate) {
|
|
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
|
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
|
|
const cachedWH = workingHoursCache.get(monthKey)!;
|
|
const cachedAH = availableHoursCache.get(monthKey)!;
|
|
// MERGE instead of replace — preserves data from other loaded months
|
|
workingHours = { ...(workingHours || {}), ...cachedWH };
|
|
availableHours = { ...(availableHours || {}), ...cachedAH };
|
|
return;
|
|
}
|
|
|
|
loadingHours = true;
|
|
try {
|
|
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
|
const endOfMonth = new CalendarDate(
|
|
date.year,
|
|
date.month,
|
|
date.calendar.getDaysInMonth(date)
|
|
);
|
|
const startStr = startOfMonth.toString();
|
|
const endStr = endOfMonth.toString();
|
|
|
|
const [whRes, ahRes] = await Promise.all([
|
|
apiFetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
|
|
apiFetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`)
|
|
]);
|
|
|
|
if (whRes.ok && ahRes.ok) {
|
|
const whData: WorkingHoursDay[] = await whRes.json();
|
|
const ahData: AvailableHoursDay[] = await ahRes.json();
|
|
|
|
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
|
whData.forEach((d) => {
|
|
whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime };
|
|
});
|
|
|
|
const ahMap: Record<
|
|
string,
|
|
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
|
> = {};
|
|
ahData.forEach((d) => {
|
|
ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots };
|
|
});
|
|
|
|
workingHoursCache.set(monthKey, whMap);
|
|
availableHoursCache.set(monthKey, ahMap);
|
|
// MERGE instead of replace — preserves data from other loaded months
|
|
workingHours = { ...(workingHours || {}), ...whMap };
|
|
availableHours = { ...(availableHours || {}), ...ahMap };
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch hours:', err);
|
|
} finally {
|
|
loadingHours = false;
|
|
}
|
|
}
|
|
|
|
async function fetchAvailableServices() {
|
|
loadingServices = true;
|
|
try {
|
|
const response = await apiFetch('/api/services');
|
|
if (response.ok) {
|
|
availableServices = (await response.json()) as Service[];
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch services:', err);
|
|
} finally {
|
|
loadingServices = false;
|
|
}
|
|
}
|
|
|
|
function calculateRemainingTime(): number {
|
|
if (!workingHours || !availableHours) return 0;
|
|
|
|
const bookingDate = parseWallClockDate(booking.start_time);
|
|
const dateStr = bookingDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
|
|
|
const dayWH = workingHours[dateStr];
|
|
const dayAH = availableHours[dateStr];
|
|
|
|
if (!dayWH?.isOpen || !dayAH?.slots) return 0;
|
|
|
|
const [startH, startM] = [bookingDate.getHours(), bookingDate.getMinutes()];
|
|
const bookingEndMinutes = startH * 60 + startM + selectedServicesDuration;
|
|
const workingEndMinutes = timeToMinutes(dayWH.endTime);
|
|
|
|
// Find next booking after current booking's end
|
|
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
|
|
let nextBookingStart = workingEndMinutes;
|
|
for (const eb of existingBookings) {
|
|
const ebStart = timeToMinutes(eb.startTime);
|
|
if (ebStart >= bookingEndMinutes && ebStart < nextBookingStart) {
|
|
nextBookingStart = ebStart;
|
|
}
|
|
}
|
|
|
|
return Math.max(0, Math.min(nextBookingStart, workingEndMinutes) - bookingEndMinutes);
|
|
}
|
|
|
|
const availableAdditionalServices = $derived(() => {
|
|
if (editMode !== 'services') return [];
|
|
const remaining = calculateRemainingTime();
|
|
if (remaining <= 0) return [];
|
|
return availableServices.filter(
|
|
(avail) =>
|
|
!selectedServices.some((selected) => selected.id === avail.id) &&
|
|
avail.duration_minutes <= remaining
|
|
);
|
|
});
|
|
|
|
async function submitEdit() {
|
|
if (!canSubmit) return;
|
|
|
|
submitting = true;
|
|
try {
|
|
// Re-check available hours to handle race conditions
|
|
if (newDate && newTime) {
|
|
const dateStr = newDate.toString();
|
|
await fetchHoursForMonth(newDate);
|
|
const dayAvailable = availableHours?.[dateStr]?.slots;
|
|
if (!dayAvailable || dayAvailable.length === 0) {
|
|
toast.error('This time slot is no longer available. Please choose a different time.');
|
|
submitting = false;
|
|
return;
|
|
}
|
|
|
|
const duration = slotDuration;
|
|
const [selHour, selMinute] = newTime.split(':').map(Number);
|
|
const selStart = selHour * 60 + selMinute;
|
|
const selEnd = selStart + duration;
|
|
|
|
const stillAvailable = dayAvailable.some((slot) => {
|
|
const [sH, sM] = slot.startTime.split(':').map(Number);
|
|
const [eH, eM] = slot.endTime.split(':').map(Number);
|
|
return selStart >= sH * 60 + sM && selEnd <= eH * 60 + eM;
|
|
});
|
|
|
|
if (!stillAvailable) {
|
|
toast.error('This slot was just taken. Please choose a different time.');
|
|
submitting = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const body: Record<string, unknown> = {};
|
|
|
|
if (editMode === 'time' || editMode === 'both-time') {
|
|
const [hours, minutes] = newTime.split(':').map(Number);
|
|
const bookingDate = newDate!.toDate(getLocalTimeZone());
|
|
bookingDate.setHours(hours || 0, minutes || 0, 0, 0);
|
|
body.new_start_time = formatLocalDateTime(bookingDate);
|
|
}
|
|
|
|
if (editMode === 'services' || editMode === 'both-time') {
|
|
body.new_services = selectedServices.map((s) => s.id);
|
|
}
|
|
|
|
if (notes.trim()) {
|
|
body.notes = notes.trim();
|
|
}
|
|
|
|
const response = await apiFetch(`/api/bookings/${booking.id}/edit-request`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const noShowWarning = response.headers.get('X-No-Show-Warning');
|
|
if (noShowWarning) {
|
|
toast.warning(noShowWarning, { duration: 8000 });
|
|
}
|
|
toast.success("Edit/reschedule request sent — we'll confirm shortly");
|
|
open = false;
|
|
onSubmitted();
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error(extractErrorMessage(text) || 'Failed to submit edit/reschedule request');
|
|
}
|
|
} catch {
|
|
toast.error('Network error');
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
function selectTime(time: string) {
|
|
newTime = time;
|
|
}
|
|
|
|
function goBack() {
|
|
if (editMode === 'both-time') {
|
|
editMode = 'both-services';
|
|
} else {
|
|
editMode = 'select';
|
|
}
|
|
}
|
|
|
|
function selectMode(mode: EditMode) {
|
|
newDate = undefined;
|
|
newTime = '';
|
|
editMode = mode;
|
|
if (mode === 'time' || mode === 'both-time') {
|
|
// Fetch hours for the current month + 2 more when entering time selection
|
|
fetchHoursRange(minDate, 3);
|
|
}
|
|
}
|
|
</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.Header>
|
|
<div class="flex items-center justify-between">
|
|
<Modal.Title class="text-lg font-semibold">{modalTitle()}</Modal.Title>
|
|
</div>
|
|
</Modal.Header>
|
|
|
|
<div class="space-y-4 px-4 pb-4">
|
|
{#if editMode === 'select'}
|
|
<!-- ─── Mode Selection ────────────────────────── -->
|
|
<div class="flex flex-col gap-3">
|
|
<p class="text-sm text-gray-600">What would you like to change?</p>
|
|
|
|
{#if noticePeriodBlocked}
|
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
|
<p class="font-medium text-amber-900">Cannot Reschedule Online</p>
|
|
<p class="mt-1">{noticeBlockedMessage}</p>
|
|
<p class="mt-1">
|
|
<a
|
|
href="/contact"
|
|
target="_blank"
|
|
rel="noopener noreferrer external"
|
|
class="underline">Contact us</a
|
|
>
|
|
to discuss options, or
|
|
<button
|
|
type="button"
|
|
onclick={() => (open = false)}
|
|
class="inline cursor-pointer underline">cancel this booking</button
|
|
>
|
|
and rebook — note that cancellation fees may apply based on our
|
|
<PolicyPopover>
|
|
{#snippet trigger()}
|
|
<span class="underline">deposit policy</span>
|
|
{/snippet}
|
|
</PolicyPopover>.
|
|
</p>
|
|
</div>
|
|
{:else if noticePeriodWarning}
|
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
|
<p class="font-medium text-amber-900">Rescheduling Within 24h</p>
|
|
<p class="mt-1">
|
|
Rescheduling within 24h counts as a no-show towards your deposit obligations. Two
|
|
no-shows within 6 months will require deposits on future bookings.
|
|
<PolicyPopover>
|
|
{#snippet trigger()}
|
|
<span class="underline">Full policy</span>
|
|
{/snippet}
|
|
</PolicyPopover>
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if booking.discounts && booking.discounts.length > 0}
|
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
|
<p class="font-medium text-amber-900">Discounts Applied to This Booking</p>
|
|
<p class="mt-1">
|
|
Your booking has £{discountTotal.toFixed(2)} in savings from loyalty stamps or promotional
|
|
offers. A time change requires admin approval. If denied, you can cancel (standard refund
|
|
policy applies) and rebook at full price.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<button
|
|
type="button"
|
|
disabled={noticePeriodBlocked}
|
|
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 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<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">
|
|
<div class="font-medium">Change Time</div>
|
|
<div class="text-sm text-gray-500">Pick a new date and time</div>
|
|
</div>
|
|
<ArrowRightIcon class="size-4 text-gray-400" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
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.'
|
|
: ''}
|
|
>
|
|
<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">
|
|
<div class="font-medium">Change Services</div>
|
|
<div class="text-sm text-gray-500">Add or remove services</div>
|
|
</div>
|
|
<ArrowRightIcon class="size-4 text-gray-400" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onclick={() => selectMode('both-services')}
|
|
disabled={hasOverrides || noticePeriodBlocked}
|
|
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.'
|
|
: ''}
|
|
>
|
|
<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="-ml-2 size-5" />
|
|
</div>
|
|
<div class="flex-1">
|
|
<div class="font-medium">Change Both</div>
|
|
<div class="text-sm text-gray-500">New time and services</div>
|
|
</div>
|
|
<ArrowRightIcon class="size-4 text-gray-400" />
|
|
</button>
|
|
|
|
{#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.
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if editMode === 'time' || editMode === 'both-time'}
|
|
<!-- ─── Time Selection ────────────────────────── -->
|
|
{#if loadingHours && !workingHours}
|
|
<div class="flex items-center justify-center p-6">
|
|
<p class="text-sm text-gray-500">Loading available dates...</p>
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center justify-center">
|
|
<DatePicker
|
|
date={newDate}
|
|
placeholder={placeholderDate}
|
|
minValue={minDate}
|
|
maxValue={maxCalendarDate}
|
|
{isDateUnavailable}
|
|
onchange={(d) => {
|
|
newDate = d;
|
|
newTime = '';
|
|
}}
|
|
onPlaceholderChange={(p) => {
|
|
userNavigatedCalendar = true;
|
|
placeholderDate = p;
|
|
fetchHoursForMonth(p);
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if newDate}
|
|
{#if loadingHours}
|
|
<div class="flex items-center justify-center border-t p-6">
|
|
<p class="text-sm text-gray-500">Loading times...</p>
|
|
</div>
|
|
{:else}
|
|
<div
|
|
class="scrollbar-hide mt-2 flex max-h-40 min-h-25 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'
|
|
})}
|
|
</div>
|
|
{#if workingHours && !workingHours[newDate.toString()]?.isOpen}
|
|
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
|
|
{:else}
|
|
{@const grouped = generateGroupedTimeSlots(
|
|
slotDuration,
|
|
newDate,
|
|
lunchProtection()
|
|
)}
|
|
{#if grouped.length > 0}
|
|
<div class="grid gap-2">
|
|
{#each grouped as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
|
|
{#if slot.type === 'available'}
|
|
<Button
|
|
variant="outline"
|
|
onclick={() => selectTime(slot.startTime)}
|
|
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>
|
|
</Button>
|
|
{: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">- {formatTime(slot.endTime)}</span>
|
|
</Button>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="text-center text-sm text-gray-500">No available slots</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<div class="mt-4 space-y-2">
|
|
<Label.Root for="edit-notes">Reason (optional)</Label.Root>
|
|
<Textarea.Root
|
|
id="edit-notes"
|
|
bind:value={notes}
|
|
placeholder="Tell us why you need to make changes"
|
|
rows={2}
|
|
/>
|
|
<CharCounter text={notes} />
|
|
</div>
|
|
{:else if editMode === 'services'}
|
|
<!-- ─── Services Only (with time restriction) ── -->
|
|
{#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.
|
|
</div>
|
|
{:else if loadingServices}
|
|
<div class="flex items-center justify-center p-6">
|
|
<p class="text-sm text-gray-500">Loading services...</p>
|
|
</div>
|
|
{:else}
|
|
{@const remaining = calculateRemainingTime()}
|
|
|
|
<div class="space-y-3">
|
|
<div>
|
|
<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>
|
|
{/if}
|
|
</h4>
|
|
{#if selectedServices.length === 0}
|
|
<p class="text-sm text-gray-500">No services selected</p>
|
|
{:else}
|
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
{#each selectedServices as service (service.id)}
|
|
<button
|
|
type="button"
|
|
onclick={() => {
|
|
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
|
}}
|
|
class="cursor-pointer rounded-lg border border-input bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50"
|
|
>
|
|
<div class="flex h-full min-h-24 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>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if remaining > 0}
|
|
{@const fittingServices = availableAdditionalServices()}
|
|
{#if fittingServices.length > 0}
|
|
<div>
|
|
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
|
Add Services
|
|
<span class="ml-1 text-xs font-normal text-gray-400">
|
|
({remaining} min remaining)
|
|
</span>
|
|
</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 h-full min-h-24 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>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-500">
|
|
No additional services can fit in the remaining time.
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<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>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mt-4 space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<Label.Root for="edit-notes-services">Special Requests</Label.Root>
|
|
{#if notesChanged}
|
|
<span class="text-xs text-amber-600">changed</span>
|
|
{/if}
|
|
</div>
|
|
{#if originalNotes}
|
|
<div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500">
|
|
<span class="font-medium">Original:</span>
|
|
{originalNotes}
|
|
</div>
|
|
{/if}
|
|
<Textarea.Root
|
|
id="edit-notes-services"
|
|
bind:value={notes}
|
|
placeholder="Any special requests or notes for your appointment"
|
|
rows={2}
|
|
/>
|
|
<CharCounter text={notes} />
|
|
</div>
|
|
{:else if editMode === 'both-services'}
|
|
<!-- ─── Both Step 1: Service Selection (no time restriction) ── -->
|
|
{#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.
|
|
</div>
|
|
{:else if loadingServices}
|
|
<div class="flex items-center justify-center p-6">
|
|
<p class="text-sm text-gray-500">Loading services...</p>
|
|
</div>
|
|
{:else}
|
|
{@const unselected = availableServices.filter(
|
|
(avail) => !selectedServices.some((selected) => selected.id === avail.id)
|
|
)}
|
|
|
|
<div class="space-y-3">
|
|
<div>
|
|
<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>
|
|
{/if}
|
|
</h4>
|
|
{#if selectedServices.length === 0}
|
|
<p class="text-sm text-gray-500">No services selected</p>
|
|
{:else}
|
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
{#each selectedServices as service (service.id)}
|
|
<button
|
|
type="button"
|
|
onclick={() => {
|
|
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
|
}}
|
|
class="cursor-pointer rounded-lg border border-input bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50"
|
|
>
|
|
<div class="flex h-full min-h-24 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>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if unselected.length > 0}
|
|
<div>
|
|
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
|
Add Services
|
|
</h4>
|
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
{#each unselected 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 h-full min-h-24 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>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if selectedServices.length === 0}
|
|
<p class="text-xs text-amber-600">Select at least one service to continue.</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-4 space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<Label.Root for="edit-notes-both-services">Special Requests</Label.Root>
|
|
{#if notesChanged}
|
|
<span class="text-xs text-amber-600">changed</span>
|
|
{/if}
|
|
</div>
|
|
{#if originalNotes}
|
|
<div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500">
|
|
<span class="font-medium">Original:</span>
|
|
{originalNotes}
|
|
</div>
|
|
{/if}
|
|
<Textarea.Root
|
|
id="edit-notes-both-services"
|
|
bind:value={notes}
|
|
placeholder="Any special requests or notes for your appointment"
|
|
rows={2}
|
|
/>
|
|
<CharCounter text={notes} />
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- ─── Footer ─────────────────────────────────────── -->
|
|
<div class="flex flex-col gap-2 border-t px-4 py-3">
|
|
<div class="flex gap-2">
|
|
{#if editMode === 'select'}
|
|
<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}>
|
|
<ArrowLeftIcon class="size-4" />
|
|
Back
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
class="flex-1 hover:bg-fuchsia-50"
|
|
disabled={!canSubmit}
|
|
loading={submitting}
|
|
onclick={submitEdit}
|
|
>
|
|
{submitting ? 'Submitting...' : 'Submit Request'}
|
|
</Button>
|
|
{:else if editMode === 'both-services'}
|
|
<Button variant="outline" size="sm" onclick={goBack}>
|
|
<ArrowLeftIcon class="size-4" />
|
|
Back
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
class="flex-1"
|
|
disabled={selectedServices.length === 0}
|
|
onclick={() => {
|
|
editMode = 'both-time';
|
|
newDate = undefined;
|
|
newTime = '';
|
|
}}
|
|
>
|
|
Next: Choose Time
|
|
<ArrowRightIcon class="size-4" />
|
|
</Button>
|
|
{:else if editMode === 'both-time'}
|
|
<Button variant="outline" size="sm" onclick={goBack}>
|
|
<ArrowLeftIcon class="size-4" />
|
|
Back
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
class="flex-1 hover:bg-fuchsia-50"
|
|
disabled={!canSubmit}
|
|
loading={submitting}
|
|
onclick={submitEdit}
|
|
>
|
|
{submitting ? 'Submitting...' : 'Submit Request'}
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</Modal.Content>
|
|
</Modal.Root>
|