@@ -12,6 +12,7 @@
import * as Card from '$lib/components/ui/card' ;
// Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs
// but keeping the Label and other components.
import { Checkbox } from '$lib/components/ui/checkbox' ;
import { Label } from '$lib/components/ui/label' ;
import { Input } from '$lib/components/ui/input' ;
import { Separator } from '$lib/components/ui/separator' ;
@@ -27,7 +28,12 @@
import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte' ;
// Types
import type { Service , CustomService , WorkingHoursDay , AvailableHoursDay } from '$lib/types/booking' ;
import type {
Service ,
CustomService ,
WorkingHoursDay ,
AvailableHoursDay
} from '$lib/types/booking' ;
import {
buildLunchProtection ,
@@ -35,6 +41,7 @@
generateGroupedTimeSlots ,
formatTime ,
calculateEndTime ,
timeToMinutes ,
getDayWithOrdinal ,
type DayHours ,
type DayAvailability
@@ -56,7 +63,15 @@
let userQuery = $state ( '' );
// Updated type to include account_role for filtering
let users = $state <
Array < { id : string ; fullName : string ; email? : string ; phone? : string ; account_role : string ; previousFirstName? : string | null ; previousLastName? : string | null } >
Array < {
id : string ;
fullName : string ;
email? : string ;
phone? : string ;
account_role : string ;
previousFirstName? : string | null ;
previousLastName? : string | null ;
} >
> ([]);
let selectedUserId = $state < string | null >( null );
let guestName = $state ( '' );
@@ -74,7 +89,13 @@
let customSearchQuery = $state ( '' );
let loadingCustomServices = $state ( false );
let showCustomCreateForm = $state ( false );
let newCustomService = $state ({ name : '' , description : '' , price : '' , duration_minutes : '' , minimum_age_required : '' });
let newCustomService = $state ({
name : '' ,
description : '' ,
price : '' ,
duration_minutes : '' ,
minimum_age_required : ''
});
let creatingCustomService = $state ( false );
let customServiceErrors = $state < Record < string , string >>({});
@@ -125,7 +146,13 @@
function toggleCustomForm ( show : boolean ) {
showCustomCreateForm = show ;
if ( show ) {
newCustomService = { name : '' , description : '' , price : '' , duration_minutes : '' , minimum_age_required : '' };
newCustomService = {
name : '' ,
description : '' ,
price : '' ,
duration_minutes : '' ,
minimum_age_required : ''
};
customServiceErrors = { name : '' , price : '' , duration_minutes : '' , minimum_age_required : '' };
requestAnimationFrame (() => {
const modalContent = document . querySelector ( '[data-custom-form-container]' );
@@ -161,6 +188,34 @@
let loadingAvailableHours = $state ( false );
let hoursRangeGeneration = $state ( 0 );
let hoursMonthGeneration = $state ( 0 );
let outOfHours = $state ( false );
// Keep the last-known normal (non-out-of-hours) working hours for per-slot styling
let normalWorkingHours = $state < Record < string , DayHours > | null >( null );
// Clear caches and force re-fetch when out-of-hours toggled
let prevOutOfHours = $state ( false );
$effect (() => {
if ( outOfHours !== prevOutOfHours ) {
prevOutOfHours = outOfHours ;
console . log ( '[DEBUG] Out-of-hours mode TOGGLED' , { now : outOfHours });
// Save normal hours BEFORE clearing, so we can show which slots are genuinely out-of-hours
if ( outOfHours && workingHours ) {
normalWorkingHours = { ... workingHours };
} else if ( ! outOfHours ) {
normalWorkingHours = null ;
}
// Clear data and caches
workingHoursCache = {};
availableHoursCache = {};
workingHours = null ;
availableHours = null ;
selectedTime = null ;
// Directly re-fetch since caches aren't reactive (plain `let` not `$state`)
// so the safety net effect won't detect the cache deletion
if ( currentStep === 4 && placeholder ) {
fetchHoursRange ( placeholder , 2 );
}
}
});
// Date Boundaries
const today = new SvelteDate ();
@@ -206,11 +261,56 @@
// =============== Lunch Protection ===============
const lunchProtection = $derived (
selectedDate && selectedServices . length > 0
selectedDate && selectedServices . length > 0 && ! outOfHours
? buildLunchProtection ( selectedDate , workingHours , availableHours , getTotalDuration (), true )
: new Map ()
);
// =============== Debug: Slot generation ===============
/** Check if a slot time falls outside the normal (non-out-of-hours) business hours */
function isSlotOutOfHours (
dateStr : string ,
timeStr : string ,
duration : number ,
normalWH : Record < string , DayHours > | null
) : boolean {
if ( ! normalWH ) return false ;
const normalDay = normalWH [ dateStr ];
if ( ! normalDay ) return false ;
// Day is normally closed → ALL slots are out-of-hours
if ( ! normalDay . isOpen ) return true ;
// Slot starts before normal opening
if ( timeToMinutes ( timeStr ) < timeToMinutes ( normalDay . startTime )) return true ;
// Slot ends after normal closing
if ( timeToMinutes ( timeStr ) + duration > timeToMinutes ( normalDay . endTime )) return true ;
return false ;
}
const groupedTimeSlots = $derived . by (() => {
const base =
currentStep === 4 && selectedServices . length > 0 && selectedDate
? generateGroupedTimeSlots (
selectedDate ,
workingHours ,
availableHours ,
getTotalDuration (),
lunchProtection
)
: [];
if ( ! outOfHours || ! normalWorkingHours || ! selectedDate ) return base ;
const dateStr = selectedDate . toString ();
const duration = getTotalDuration ();
return base . map (( slot ) => {
if ( slot . type === 'available' ) {
return {
... slot ,
outOfHours : isSlotOutOfHours ( dateStr , slot . startTime , duration , normalWorkingHours )
};
}
return slot ;
});
});
function formatDuration ( minutes : number ) : string {
const hours = Math . floor ( minutes / 60 );
const remainingMinutes = minutes % 60 ;
@@ -233,6 +333,18 @@
const canProceedStep3 = $derived ( true ); // Overrides are optional
const canProceedStep4 = $derived ( !! ( selectedDate && selectedTime ));
/** Whether the currently selected time slot is out-of-hours */
const selectedTimeOutOfHours = $derived (
outOfHours && selectedDate && selectedTime && normalWorkingHours
? isSlotOutOfHours (
selectedDate . toString (),
selectedTime ,
getTotalDuration (),
normalWorkingHours
)
: false
);
// =============== Effects ===============
let wasOpen = false ;
let userNavigatedCalendar = $state ( false );
@@ -306,7 +418,17 @@
checkDate . getMonth () + 1 ,
checkDate . getDate ()
);
if ( workingHours [ dateStr ] ? . isOpen && ! isDateUnavailable ( calDate ) ) {
if ( outOfHours ) {
// Out-of-hours: just check available hours exist with slots
const dayAH = availableHours ? .[ dateStr ];
if ( dayAH ? . slots ? . length > 0 && ! isDateUnavailable ( calDate )) {
selectedDate = calDate ;
if ( ! userNavigatedCalendar ) {
placeholder = new CalendarDate ( checkDate . getFullYear (), checkDate . getMonth () + 1 , 1 );
}
break ;
}
} else if ( workingHours [ dateStr ] ? . isOpen && ! isDateUnavailable ( calDate )) {
selectedDate = calDate ;
if ( ! userNavigatedCalendar ) {
placeholder = new CalendarDate ( checkDate . getFullYear (), checkDate . getMonth () + 1 , 1 );
@@ -339,6 +461,8 @@
userNavigatedCalendar = false ;
bookingCreateAutoSelectDone = false ;
loadingMonthKeys = new Set ();
outOfHours = false ;
normalWorkingHours = null ;
// Clear reservation state
reservationId = null ;
reservationExpiresAt = null ;
@@ -419,18 +543,40 @@
try {
const [ whRes , ahRes ] = await Promise . all ([
fetch ( `/api/scheduling/working-hours?start= ${ startStr } &end= ${ endStr } ` , {
fetch (
`/api/scheduling/working-hours?start= ${ startStr } &end= ${ endStr }${ outOfHours ? '&out_of_hours=true' : '' } ` ,
{
headers : { Authorization : `Bearer ${ authStore . currentToken } ` }
}),
fetch ( `/api/scheduling/available-hours?start= ${ startStr } &end= ${ endStr } ` , {
}
),
fetch (
`/api/scheduling/available-hours?start= ${ startStr } &end= ${ endStr }${ outOfHours ? '&out_of_hours=true' : '' } ` ,
{
headers : { Authorization : `Bearer ${ authStore . currentToken } ` }
})
}
)
]);
if ( whRes . ok && ahRes . ok ) {
const whData : WorkingHoursDay [] = await whRes . json ();
const ahData : AvailableHoursDay [] = await ahRes . json ();
console . log ( '[DEBUG] API response for hours' , {
outOfHours ,
start : startStr ,
end : endStr ,
mode : outOfHours ? 'out_of_hours' : 'normal' ,
whSample : whData.slice ( 0 , 3 ). map (( d ) => ({
date : d.date ,
isOpen : d.isOpen ,
startTime : d.startTime ,
endTime : d.endTime
})),
ahSample : ahData
. slice ( 0 , 3 )
. map (( d ) => ({ date : d.date , isOpen : d.isOpen , slotsCount : d.slots?.length }))
});
const whMap : Record < string , any > = {};
const ahMap : Record < string , any > = {};
@@ -496,12 +642,18 @@
);
const [ whRes , ahRes ] = await Promise . all ([
fetch ( `/api/scheduling/working-hours?start= ${ startOfMonth } &end= ${ endOfMonth } ` , {
fetch (
`/api/scheduling/working-hours?start= ${ startOfMonth } &end= ${ endOfMonth }${ outOfHours ? '&out_of_hours=true' : '' } ` ,
{
headers : { Authorization : `Bearer ${ authStore . currentToken } ` }
}),
fetch ( `/api/scheduling/available-hours?start= ${ startOfMonth } &end= ${ endOfMonth } ` , {
}
),
fetch (
`/api/scheduling/available-hours?start= ${ startOfMonth } &end= ${ endOfMonth }${ outOfHours ? '&out_of_hours=true' : '' } ` ,
{
headers : { Authorization : `Bearer ${ authStore . currentToken } ` }
})
}
)
]);
if ( whRes . ok && ahRes . ok ) {
@@ -547,8 +699,10 @@
localDate . setHours ( hours , minutes , 0 , 0 );
const startTimeISO = localDate . toISOString ();
const serviceIds = selectedServices . filter ( s => ! ( s as any ). is_custom ). map (( s ) => s . id );
const customServiceIds = selectedServices . filter ( s => ( s as any ). is_custom ). map (( s ) => s . id );
const serviceIds = selectedServices . filter (( s ) => ! ( s as any ). is_custom ). map (( s ) => s . id );
const customServiceIds = selectedServices
. filter (( s ) => ( s as any ). is_custom )
. map (( s ) => s . id );
// Build service overrides payload
const overrides = [];
@@ -568,7 +722,8 @@
service_ids : serviceIds ,
service_overrides : overrides.length > 0 ? overrides : [],
ttl_minutes : 15 ,
reservation_type : 'callin'
reservation_type : 'callin' ,
out_of_hours : outOfHours
};
if ( customServiceIds . length > 0 ) {
payload . custom_service_ids = customServiceIds ;
@@ -731,7 +886,13 @@
}
};
showCustomCreateForm = false ;
newCustomService = { name : '' , description : '' , price : '' , duration_minutes : '' , minimum_age_required : '' };
newCustomService = {
name : '' ,
description : '' ,
price : '' ,
duration_minutes : '' ,
minimum_age_required : ''
};
customServiceErrors = { name : '' , price : '' , duration_minutes : '' };
toast . success ( 'Custom service created and added' );
} else {
@@ -745,24 +906,29 @@
}
}
const groupedTimeSlots = $derived (
currentStep === 4 && selectedServices . length > 0 && selectedDate
? generateGroupedTimeSlots (
selectedDate ,
workingHours ,
availableHours ,
getTotalDuration (),
lunchProtection
)
: []
);
function isDateUnavailable ( date : DateValue ) : boolean {
if ( ! ( date instanceof CalendarDate )) return true ;
if ( date . compare ( minDate ) < 0 || date . compare ( maxCalendarDate ) > 0 ) return true ;
if ( ! workingHours ) return true ;
const dateStr = date . toString ();
// Out-of-hours: only check if available hours exist with slots
if ( outOfHours ) {
const ahDay = availableHours ? .[ dateStr ];
const whDay = workingHours [ dateStr ];
const result = ! ahDay ? . slots || ahDay . slots . length === 0 ;
console . log ( '[DEBUG] isDateUnavailable (outOfHours)' , {
dateStr ,
result ,
hasSlots : ahDay?.slots?.length ,
whIsOpen : whDay?.isOpen ,
whStart : whDay?.startTime ,
whEnd : whDay?.endTime
});
return result ;
}
const dayHours = workingHours [ dateStr ];
if ( ! dayHours ? . isOpen ) return true ;
@@ -865,10 +1031,11 @@
const payload : Record < string , any > = {
user_id : finalUserId ,
start_time : dateTimeStr ,
service_ids : selectedServices.filter ( s => ! ( s as any ). is_custom ). map (( s ) => s . id ),
custom_service_ids : selectedServices.filter ( s => ( s as any ). is_custom ). map (( s ) => s . id ),
service_ids : selectedServices.filter (( s ) => ! ( s as any ). is_custom ). map (( s ) => s . id ),
custom_service_ids : selectedServices.filter (( s ) => ( s as any ). is_custom ). map (( s ) => s . id ),
service_overrides : overrides.length > 0 ? overrides : undefined ,
notes : notes.trim () || null
notes : notes.trim () || null ,
out_of_hours : outOfHours
};
const res = await fetch ( '/api/admin/bookings' , {
@@ -1042,7 +1209,13 @@
onclick = {() => ( selectedUserId = user . id )}
>
< div >
< div class = "text-base font-medium" > { formatUserName ( user . fullName , user . previousFirstName , user . previousLastName )} </ div >
< div class = "text-base font-medium" >
{ formatUserName (
user . fullName ,
user . previousFirstName ,
user . previousLastName
)}
</ div >
< div class = "text-xs text-gray-500" >
{ #if user . email && user . phone }
{ user . email } • { user . phone }
@@ -1149,7 +1322,9 @@
< Input
placeholder = "Search existing custom services..."
bind:value = { customSearchQuery }
onkeydown= {( e ) => { if ( e . key === 'Enter' ) fetchCustomServices (); }}
onkeydown= {( e ) => {
if ( e . key === 'Enter' ) fetchCustomServices ();
}}
class = "flex-1"
/>
< Button variant = "outline" size = "sm" onclick = { fetchCustomServices } > Search</Button >
@@ -1182,12 +1357,23 @@
}}
>
<span class=" font - medium " > { cs . name }</ span >
< span class = "text-gray-500" >{ cs . duration_minutes } min • £ { cs . price . toFixed ( 2 )}{ cs . usage_count > 0 ? ` ($ { cs . usage_count } × ) ` : '' }</ span >
< span class = "text-gray-500"
>{ cs . duration_minutes } min • £ { cs . price . toFixed ( 2 )}{ cs . usage_count > 0
? ` ($ { cs . usage_count } × ) `
: '' } < /span
>
</ button >
{ /each }
</ div >
{ /if }
< Button variant = "ghost" size = "sm" onclick = {() => { showCustomCreateForm = true ; }} class = "w-full" >
< Button
variant = "ghost"
size = "sm"
onclick = {() => {
showCustomCreateForm = true ;
}}
class = "w-full"
>
+ Create new custom service
</ Button >
</ div >
@@ -1198,8 +1384,10 @@
< Input
id = "booking-cs-name"
bind : value = { newCustomService . name }
oninput = {() => customServiceErrors . name = validateCsName ( newCustomService . name )}
onblur = {() => customServiceErrors . name = validateCsName ( newCustomService . name )}
oninput = {() =>
( customServiceErrors . name = validateCsName ( newCustomService . name )) }
onblur = {() =>
( customServiceErrors . name = validateCsName ( newCustomService . name ))}
placeholder = "e.g., Bridal Party French Tips"
class = { customServiceErrors . name ? 'border-red-500' : '' }
/>
@@ -1225,8 +1413,10 @@
step = "0.01"
min = "0"
bind : value = { newCustomService . price }
oninput = {() => customServiceErrors . price = validateCsPrice ( newCustomService . price )}
onblur = {() => customServiceErrors . price = validateCsPrice ( newCustomService . price )}
oninput = {() =>
( customServiceErrors . price = validateCsPrice ( newCustomService . price )) }
onblur = {() =>
( customServiceErrors . price = validateCsPrice ( newCustomService . price ))}
placeholder = "0.00"
class = { customServiceErrors . price ? 'border-red-500' : '' }
/>
@@ -1239,12 +1429,21 @@
< select
id = "booking-cs-dur"
bind : value = { newCustomService . duration_minutes }
onchange = {() => customServiceErrors . duration_minutes = validateCsDuration ( newCustomService . duration_minutes )}
class = " flex h - 10 w - full rounded - md border border - input bg - background px - 3 py - 2 text - sm ring - offset - background focus - visible :outline - none focus - visible :ring - 2 focus - visible :ring - ring focus - visible :ring - offset - 2 { customServiceErrors . duration_minutes ? 'border-red-500' : '' } "
onchange = {() =>
( customServiceErrors . duration_minutes = validateCsDuration (
newCustomService . duration_minutes
))}
class = " flex h - 10 w - full rounded - md border border - input bg - background px - 3 py - 2 text - sm ring - offset - background focus - visible :ring - 2 focus - visible :ring - ring focus - visible :ring - offset - 2 focus - visible :outline - none { customServiceErrors . duration_minutes
? 'border-red-500'
: '' } "
>
<option value="">Select...</option>
{#each durationOptions as mins (mins)}
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
<option value={mins}
>{mins} min{mins >= 60
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
: ''}</option
>
{/each}
</select>
{#if customServiceErrors.duration_minutes}
@@ -1262,8 +1461,13 @@
max = "100"
placeholder = "0"
bind : value = { newCustomService . minimum_age_required }
oninput = {() => customServiceErrors . minimum_age_required = validateCsMinimumAge ( newCustomService . minimum_age_required )}
class = " w - full { customServiceErrors . minimum_age_required ? 'border-red-500' : '' } "
oninput = {() =>
( customServiceErrors . minimum_age_required = validateCsMinimumAge (
newCustomService . minimum_age_required
))}
class = " w - full { customServiceErrors . minimum_age_required
? 'border-red-500'
: '' } "
/>
{#if customServiceErrors.minimum_age_required}
<p class=" text - xs text - red - 600 " > { customServiceErrors . minimum_age_required }</ p >
@@ -1271,10 +1475,33 @@
< p class = "text-xs text-gray-500" > 0 for no age restriction </ p >
</ div >
< div class = "flex gap-2" >
< Button size = "sm" onclick = { createCustomService } disabled = { creatingCustomService || ! isCustomFormValid } >
< Button
size = "sm"
onclick = { createCustomService }
disabled = { creatingCustomService || ! isCustomFormValid }
>
{ creatingCustomService ? 'Creating...' : 'Save & Add' }
</ Button >
< Button variant = "outline" size = "sm" onclick = {() => { showCustomCreateForm = false ; newCustomService = { name : '' , description : '' , price : '' , duration_minutes : '' , minimum_age_required : '' }; customServiceErrors = { name : '' , price : '' , duration_minutes : '' , minimum_age_required : '' }; }} >
< Button
variant = "outline"
size = "sm"
onclick = {() => {
showCustomCreateForm = false ;
newCustomService = {
name : '' ,
description : '' ,
price : '' ,
duration_minutes : '' ,
minimum_age_required : ''
};
customServiceErrors = {
name : '' ,
price : '' ,
duration_minutes : '' ,
minimum_age_required : ''
};
}}
>
Cancel
</ Button >
</ div >
@@ -1481,6 +1708,14 @@
/>
</ div >
<!-- Out-of-hours toggle -->
< div class = "mx-6 flex items-center gap-2" >
< Checkbox id = "out-of-hours" bind : checked = { outOfHours } />
< Label for = "out-of-hours" class = "cursor-pointer text-sm font-medium text-amber-600" >
Out - of - hours booking
</ Label >
</ div >
{ #if loadingAvailableHours && selectedDate }
< div class = "flex items-center justify-center border-t p-6" >
< p class = "text-sm text-gray-500" > Loading times ...</ p >
@@ -1512,6 +1747,7 @@
endTime = { formatTime ( calculateEndTime ( selectedTime , getTotalDuration ()))}
duration = { getTotalDuration ()}
protection = { lunchProtection . get ( selectedTime )}
outOfHours = { selectedTimeOutOfHours }
/>
</ div >
{ /if }