Files
Crussell/frontend/src/lib/components/account/EditRequestModal.svelte
T

1193 lines
40 KiB
Svelte

<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
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 type { Booking, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import { extractBookedSlots, getLunchProtectionForSlots, timeToMinutes } from '$lib/lunchProtection';
import ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
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 today = new Date();
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new Date();
maxDate.setMonth(today.getMonth() + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
maxDate.getMonth() + 1,
maxDate.getDate()
);
let placeholderDate = $state<CalendarDate>(minDate);
let bookingTotalDuration = $derived(
booking.services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
0
) || 0
);
let selectedServicesDuration = $derived(
selectedServices.reduce((sum, s) => sum + (s.duration_minutes ?? 0), 0)
);
let hasOverrides = $derived(
booking?.services?.some(
(s) => s.override_price != null || s.override_duration_minutes != null
) ?? false
);
let slotDuration = $derived(
editMode === 'time' ? bookingTotalDuration : selectedServicesDuration
);
let canSubmit = $derived(
submitting === false && (
(editMode === 'time' && !!newDate && newTime.length >= 4) ||
(editMode === 'services' && selectedServices.length > 0) ||
(editMode === 'both-time' && !!newDate && newTime.length >= 4)
)
);
let notesChanged = $derived(notes.trim() !== originalNotes.trim());
let modalTitle = $derived(() => {
switch (editMode) {
case 'select': return 'Edit Request';
case 'time': return 'Change Time';
case 'services': return 'Change Services';
case 'both-services': return 'Change Services';
case 'both-time': return 'Choose Time';
}
});
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);
let 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);
let 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 now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
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 currentMin = now.getHours() * 60 + now.getMinutes();
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 now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(todayCal) === 0;
if (isToday) {
const currentMin = now.getHours() * 60 + now.getMinutes();
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;
const jsDate = d.toDate(getLocalTimeZone());
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (jsDate < todayStart) return true;
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();
}
});
$effect(() => {
if (editMode === 'services') {
// Fetch hours for the booking's current date to calculate remaining time
const bookingDate = new Date(booking.start_time);
const calDate = new CalendarDate(
bookingDate.getFullYear(),
bookingDate.getMonth() + 1,
bookingDate.getDate()
);
fetchHoursForMonth(calDate);
}
});
// ─── Auto-select ────────────────────────────────────────
$effect(() => {
if (!workingHours || !availableHours || newDate || userNavigatedCalendar || editRequestAutoSelectDone) return;
editRequestAutoSelectDone = 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 = 0; 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)) {
newDate = calDate;
if (!userNavigatedCalendar) {
placeholderDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1
);
}
return;
}
}
}
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
newDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
if (!userNavigatedCalendar) {
placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
}
});
// ─── 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([
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
fetch(`/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([
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
fetch(`/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 fetch('/api/services', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
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 = new Date(booking.start_time);
const dateStr = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`;
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);
}
let 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 = bookingDate.toISOString();
}
if (editMode === 'services' || editMode === 'both-time') {
body.new_services = selectedServices.map((s) => s.id);
}
if (notes.trim()) {
body.notes = notes.trim();
}
const response = await fetch(`/api/bookings/${booking.id}/edit-request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(body)
});
if (response.ok) {
toast.success("Edit request sent — we'll confirm shortly");
open = false;
onSubmitted();
} else {
const text = await response.text();
toast.error(text || 'Failed to submit edit 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>
<button
type="button"
onclick={() => selectMode('time')}
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
<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}
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="size-5 -ml-2" />
</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={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="no-scrollbar mt-2 flex min-h-[100px] max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4">
<div class="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 ? 'bg-fuchsia-100 border-fuchsia-200' : ''}"
>
{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}
/>
</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-background p-4 text-left transition-colors hover:bg-fuchsia-50 bg-fuchsia-100"
>
<div class="flex flex-col justify-between h-full min-h-[6rem]">
<div>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p>
</div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
</div>
</div>
</button>
{/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 flex-col justify-between h-full min-h-[6rem]">
<div>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p>
</div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
</div>
</div>
</button>
{/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}
/>
</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-background p-4 text-left transition-colors hover:bg-fuchsia-50 bg-fuchsia-100"
>
<div class="flex flex-col justify-between h-full min-h-[6rem]">
<div>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p>
</div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
</div>
</div>
</button>
{/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 flex-col justify-between h-full min-h-[6rem]">
<div>
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description || ''}</p>
</div>
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
</div>
</div>
</button>
{/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}
/>
</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>