refactor(frontend): timezone-safe date handling with London-aware utilities
Introduce getLondonTodayCalendarDate(), parseWallClockDate(), and formatLocalDateTime() for reliable Europe/London timezone handling. Replace ad-hoc SvelteDate/new Date() usage with these utilities across all components and stores. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Vendored
+6
@@ -8,6 +8,12 @@ declare global {
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__walkInCountdownInterval?: ReturnType<typeof setInterval>;
|
||||
__walkInModalCountdownInterval?: ReturnType<typeof setInterval>;
|
||||
__bookingCreateCountdownInterval?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
getLunchProtectionForSlots,
|
||||
timeToMinutes
|
||||
} from '$lib/lunchProtection';
|
||||
import { formatLocalDateTime, getLondonTodayCalendarDate } 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';
|
||||
@@ -67,10 +68,10 @@
|
||||
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 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,
|
||||
@@ -204,8 +205,7 @@
|
||||
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 todayCal = getLondonTodayCalendarDate();
|
||||
const isToday = date.compare(todayCal) === 0;
|
||||
|
||||
for (const slot of dayAH.slots) {
|
||||
@@ -217,7 +217,10 @@
|
||||
const endMin = eh * 60 + em;
|
||||
|
||||
if (isToday) {
|
||||
const currentMin = now.getHours() * 60 + now.getMinutes();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -261,11 +264,13 @@
|
||||
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 todayCal = getLondonTodayCalendarDate();
|
||||
const isToday = date.compare(todayCal) === 0;
|
||||
if (isToday) {
|
||||
const currentMin = now.getHours() * 60 + now.getMinutes();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -324,10 +329,6 @@
|
||||
|
||||
function isDateUnavailable(date: DateValue): boolean {
|
||||
const d = date as CalendarDate;
|
||||
const jsDate = d.toDate(getLocalTimeZone());
|
||||
const now = new SvelteDate();
|
||||
const todayStart = new SvelteDate(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();
|
||||
@@ -431,7 +432,7 @@
|
||||
return;
|
||||
editRequestAutoSelectDone = true;
|
||||
|
||||
const currentDate = new SvelteDate();
|
||||
const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new SvelteDate(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
@@ -446,7 +447,7 @@
|
||||
for (let i = 0; i <= daysToCheck; i++) {
|
||||
const nextDate = new SvelteDate(currentDate);
|
||||
nextDate.setDate(currentDate.getDate() + i);
|
||||
const dateStr = nextDate.toISOString().split('T')[0];
|
||||
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
|
||||
if (workingHours[dateStr]?.isOpen) {
|
||||
const calDate = new CalendarDate(
|
||||
@@ -464,11 +465,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
const tomorrow = new SvelteDate();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
newDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, tomorrow.getDate());
|
||||
const tomorrowCal = getLondonTodayCalendarDate();
|
||||
newDate = new CalendarDate(
|
||||
tomorrowCal.year,
|
||||
tomorrowCal.month,
|
||||
tomorrowCal.day + 1
|
||||
);
|
||||
if (!userNavigatedCalendar) {
|
||||
placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
||||
placeholderDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -612,7 +616,7 @@
|
||||
if (!workingHours || !availableHours) return 0;
|
||||
|
||||
const bookingDate = new SvelteDate(booking.start_time);
|
||||
const dateStr = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`;
|
||||
const dateStr = bookingDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
|
||||
const dayWH = workingHours[dateStr];
|
||||
const dayAH = availableHours[dateStr];
|
||||
@@ -687,7 +691,7 @@
|
||||
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();
|
||||
body.new_start_time = formatLocalDateTime(bookingDate);
|
||||
}
|
||||
|
||||
if (editMode === 'services' || editMode === 'both-time') {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script lang="ts">
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
||||
import { computeBalanceDue } from '$lib/utils/booking';
|
||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
||||
import { computeBalanceDue } from '$lib/utils/booking';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
bookingId: string;
|
||||
@@ -306,7 +307,7 @@
|
||||
<h2>Receipt</h2>
|
||||
<table>
|
||||
<tr><td style="width:110px;font-weight:600">Booking Ref</td><td>${esc(selectedBooking.id)}</td></tr>
|
||||
<tr><td style="font-weight:600">Date</td><td>${new Date(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</td></tr>
|
||||
<tr><td style="font-weight:600">Date</td><td>${parseWallClockDate(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</td></tr>
|
||||
<tr><td style="font-weight:600">Status</td><td style="text-transform:capitalize">${selectedBooking.status.replace('_', ' ')}</td></tr>
|
||||
</table>
|
||||
<h2>Services</h2>
|
||||
@@ -474,7 +475,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
||||
<div class="font-medium">
|
||||
{(() => {
|
||||
const date = new SvelteDate(selectedBooking.start_time);
|
||||
const date = parseWallClockDate(selectedBooking.start_time);
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
@@ -552,7 +553,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</span>
|
||||
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
|
||||
<span class="text-gray-500">
|
||||
• Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString(
|
||||
• Due: {parseWallClockDate(selectedBooking.deposit_deadline).toLocaleDateString(
|
||||
'en-GB',
|
||||
{
|
||||
weekday: 'short',
|
||||
@@ -560,7 +561,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}
|
||||
)} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString(
|
||||
)} at {parseWallClockDate(selectedBooking.deposit_deadline).toLocaleTimeString(
|
||||
'en-GB',
|
||||
{
|
||||
hour: 'numeric',
|
||||
@@ -778,7 +779,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
<p class="font-medium">Awaiting admin approval</p>
|
||||
<p class="mt-0.5 text-amber-700">
|
||||
{#if timeChanged}
|
||||
Reschedule requested from {new SvelteDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {new SvelteDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
Reschedule requested from {parseWallClockDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {parseWallClockDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
{/if}
|
||||
</p>
|
||||
{#if addedServices.length > 0}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -73,7 +74,7 @@
|
||||
|
||||
function getBookingDateTime(): string {
|
||||
if (!booking?.start_time) return '';
|
||||
const d = new SvelteDate(booking.start_time);
|
||||
const d = parseWallClockDate(booking.start_time);
|
||||
return (
|
||||
d.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
@@ -152,7 +153,7 @@
|
||||
overlappingBookings = data.bookings || [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching overlapping bookings:', err);
|
||||
// Silently handle - overlapping bookings couldn't be fetched
|
||||
} finally {
|
||||
loadingOverlaps = false;
|
||||
}
|
||||
@@ -340,7 +341,6 @@
|
||||
toast.error('Failed to confirm: ' + sanitizeText(text), { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error confirming booking:', err);
|
||||
toast.error('Network error confirming booking', { id: loadingToast });
|
||||
} finally {
|
||||
submitting = false;
|
||||
@@ -370,7 +370,6 @@
|
||||
toast.error('Failed to decline: ' + sanitizeText(text), { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error declining booking:', err);
|
||||
toast.error('Network error declining booking', { id: loadingToast });
|
||||
} finally {
|
||||
submitting = false;
|
||||
@@ -417,7 +416,7 @@
|
||||
{formatUserName(ob.user?.full_name || 'Unknown', ob.user?.previous_first_name, ob.user?.previous_last_name)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{new SvelteDate(ob.start_time).toLocaleDateString('en-GB', {
|
||||
{parseWallClockDate(ob.start_time).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
|
||||
@@ -40,9 +40,12 @@
|
||||
generateAvailableTimeSlots,
|
||||
generateGroupedTimeSlots,
|
||||
formatTime,
|
||||
formatLocalDateTime,
|
||||
calculateEndTime,
|
||||
timeToMinutes,
|
||||
getDayWithOrdinal,
|
||||
parseWallClockDate,
|
||||
getLondonTodayCalendarDate,
|
||||
type DayHours,
|
||||
type DayAvailability
|
||||
} from '$lib/utils/timeSlots';
|
||||
@@ -173,13 +176,7 @@
|
||||
>({});
|
||||
|
||||
// Step 4: Date & Time
|
||||
let placeholder = $state<CalendarDate>(
|
||||
new CalendarDate(
|
||||
new SvelteDate().getFullYear(),
|
||||
new SvelteDate().getMonth() + 1,
|
||||
new SvelteDate().getDate()
|
||||
)
|
||||
);
|
||||
let placeholder = $state<CalendarDate>(getLondonTodayCalendarDate());
|
||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||
let selectedTime = $state<string | null>(null);
|
||||
let workingHours = $state<Record<string, DayHours> | null>(null);
|
||||
@@ -196,7 +193,6 @@
|
||||
$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 };
|
||||
@@ -218,10 +214,10 @@
|
||||
});
|
||||
|
||||
// Date Boundaries
|
||||
const today = new SvelteDate();
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const maxDate = new SvelteDate();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const minDate = today;
|
||||
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
|
||||
maxDate.setMonth(today.month - 1 + 6);
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
maxDate.getMonth() + 1,
|
||||
@@ -237,8 +233,8 @@
|
||||
let isReserving = $state(false);
|
||||
|
||||
// =============== Cache ===============
|
||||
let workingHoursCache: Record<string, Record<string, any>> = {};
|
||||
let availableHoursCache: Record<string, Record<string, any>> = {};
|
||||
let workingHoursCache: Record<string, Record<string, DayHours>> = {};
|
||||
let availableHoursCache: Record<string, Record<string, DayAvailability>> = {};
|
||||
let loadingMonthKeys: Set<string> = new Set();
|
||||
|
||||
// =============== Derived Helpers ===============
|
||||
@@ -399,7 +395,7 @@
|
||||
!bookingCreateAutoSelectDone
|
||||
) {
|
||||
bookingCreateAutoSelectDone = true;
|
||||
const now = new SvelteDate();
|
||||
const now = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new SvelteDate(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
@@ -412,7 +408,7 @@
|
||||
for (let i = 0; i <= daysToCheck; i++) {
|
||||
const checkDate = new SvelteDate(now);
|
||||
checkDate.setDate(now.getDate() + i);
|
||||
const dateStr = checkDate.toISOString().split('T')[0];
|
||||
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const calDate = new CalendarDate(
|
||||
checkDate.getFullYear(),
|
||||
checkDate.getMonth() + 1,
|
||||
@@ -467,8 +463,8 @@
|
||||
reservationId = null;
|
||||
reservationExpiresAt = null;
|
||||
reservationCountdown = '';
|
||||
if ((window as any).__bookingCreateCountdownInterval) {
|
||||
clearInterval((window as any).__bookingCreateCountdownInterval);
|
||||
if (window.__bookingCreateCountdownInterval) {
|
||||
clearInterval(window.__bookingCreateCountdownInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +488,6 @@
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users', err);
|
||||
toast.error('Failed to load users');
|
||||
} finally {
|
||||
loadingUsers = false;
|
||||
@@ -514,7 +509,6 @@
|
||||
services = await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch services', err);
|
||||
toast.error('Failed to load services');
|
||||
} finally {
|
||||
loadingServices = false;
|
||||
@@ -561,24 +555,8 @@
|
||||
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> = {};
|
||||
const whMap: Record<string, DayHours> = {};
|
||||
const ahMap: Record<string, DayAvailability> = {};
|
||||
|
||||
whData.forEach(
|
||||
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
|
||||
@@ -605,7 +583,6 @@
|
||||
availableHours = { ...availableHours, ...ahMap };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hours', err);
|
||||
toast.error('Failed to load availability');
|
||||
} finally {
|
||||
_loadingWorkingHours = false;
|
||||
@@ -660,8 +637,8 @@
|
||||
const whData: WorkingHoursDay[] = await whRes.json();
|
||||
const ahData: AvailableHoursDay[] = await ahRes.json();
|
||||
|
||||
const whMap: Record<string, any> = {};
|
||||
const ahMap: Record<string, any> = {};
|
||||
const whMap: Record<string, DayHours> = {};
|
||||
const ahMap: Record<string, DayAvailability> = {};
|
||||
|
||||
whData.forEach(
|
||||
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
|
||||
@@ -676,7 +653,6 @@
|
||||
availableHours = { ...availableHours, ...ahMap };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hours', err);
|
||||
toast.error('Failed to load availability');
|
||||
} finally {
|
||||
_loadingWorkingHours = false;
|
||||
@@ -697,11 +673,11 @@
|
||||
const localDate = selectedDate.toDate(getLocalTimeZone());
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
localDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = localDate.toISOString();
|
||||
const startTimeISO = formatLocalDateTime(localDate);
|
||||
|
||||
const serviceIds = selectedServices.filter((s) => !(s as any).is_custom).map((s) => s.id);
|
||||
const serviceIds = selectedServices.filter((s) => !s.is_custom).map((s) => s.id);
|
||||
const customServiceIds = selectedServices
|
||||
.filter((s) => (s as any).is_custom)
|
||||
.filter((s) => s.is_custom)
|
||||
.map((s) => s.id);
|
||||
|
||||
// Build service overrides payload
|
||||
@@ -716,7 +692,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
const payload: {
|
||||
user_id: string | null;
|
||||
start_time: string;
|
||||
service_ids: string[];
|
||||
service_overrides: Array<{ service_id: string; override_duration_minutes: number }>;
|
||||
ttl_minutes: number;
|
||||
reservation_type: string;
|
||||
out_of_hours: boolean;
|
||||
custom_service_ids?: string[];
|
||||
} = {
|
||||
user_id: selectedUserId || null,
|
||||
start_time: startTimeISO,
|
||||
service_ids: serviceIds,
|
||||
@@ -760,7 +745,6 @@
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Reservation error:', err);
|
||||
toast.error('Failed to reserve slot');
|
||||
return false;
|
||||
} finally {
|
||||
@@ -770,8 +754,8 @@
|
||||
|
||||
function startCountdown() {
|
||||
// Clear any existing interval
|
||||
if ((window as any).__bookingCreateCountdownInterval) {
|
||||
clearInterval((window as any).__bookingCreateCountdownInterval);
|
||||
if (window.__bookingCreateCountdownInterval) {
|
||||
clearInterval(window.__bookingCreateCountdownInterval);
|
||||
}
|
||||
|
||||
const updateCountdown = () => {
|
||||
@@ -787,8 +771,8 @@
|
||||
reservationCountdown = 'Expired';
|
||||
reservationId = null;
|
||||
reservationExpiresAt = null;
|
||||
if ((window as any).__bookingCreateCountdownInterval) {
|
||||
clearInterval((window as any).__bookingCreateCountdownInterval);
|
||||
if (window.__bookingCreateCountdownInterval) {
|
||||
clearInterval(window.__bookingCreateCountdownInterval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -799,7 +783,7 @@
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
(window as any).__bookingCreateCountdownInterval = setInterval(updateCountdown, 1000);
|
||||
window.__bookingCreateCountdownInterval = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
// =============== Logic ===============
|
||||
@@ -841,7 +825,7 @@
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const list = data.services || data;
|
||||
customServices = list.map((cs: any) => ({ ...cs, is_custom: true }));
|
||||
customServices = list.map((cs: CustomService) => ({ ...cs, is_custom: true }));
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to fetch custom services');
|
||||
@@ -916,17 +900,7 @@
|
||||
// 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;
|
||||
return !ahDay?.slots || ahDay.slots.length === 0;
|
||||
}
|
||||
|
||||
const dayHours = workingHours[dateStr];
|
||||
@@ -1012,7 +986,7 @@
|
||||
const localDate = selectedDate.toDate(getLocalTimeZone());
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
localDate.setHours(hours, minutes, 0, 0);
|
||||
const dateTimeStr = localDate.toISOString();
|
||||
const dateTimeStr = formatLocalDateTime(localDate);
|
||||
|
||||
const overrides = [];
|
||||
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
|
||||
@@ -1028,11 +1002,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
const payload: {
|
||||
user_id: string;
|
||||
start_time: string;
|
||||
service_ids: string[];
|
||||
custom_service_ids: string[];
|
||||
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
|
||||
notes: string | null;
|
||||
out_of_hours: boolean;
|
||||
} = {
|
||||
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.is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter((s) => s.is_custom).map((s) => s.id),
|
||||
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||
notes: notes.trim() || null,
|
||||
out_of_hours: outOfHours
|
||||
@@ -1054,11 +1036,9 @@
|
||||
onBookingCreated?.();
|
||||
} else {
|
||||
const errorText = await res.text();
|
||||
console.error('Booking creation failed:', errorText);
|
||||
toast.error(`Failed to create booking: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Booking submission error:', err);
|
||||
toast.error('An error occurred while creating booking');
|
||||
} finally {
|
||||
submitting = false;
|
||||
@@ -1677,7 +1657,7 @@
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-green-800">
|
||||
Slot reserved until {reservationExpiresAt.toLocaleTimeString([], {
|
||||
Slot reserved until {parseWallClockDate(reservationExpiresAt.toISOString()).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
|
||||
@@ -118,10 +118,14 @@
|
||||
}
|
||||
|
||||
function validateVatNumber(value: unknown): string {
|
||||
const v = value === null || value === undefined ? '' : String(value);
|
||||
const trimmed = v.trim();
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.length > 20) return 'Must be 20 characters or fewer';
|
||||
// UK VAT numbers: GB + 9 digits (standard) or GB + 12 digits (branch)
|
||||
if (trimmed.length !== 11 && trimmed.length !== 14) return 'Must be GB followed by 9 or 12 digits';
|
||||
if (!trimmed.startsWith('GB')) return 'Must start with GB';
|
||||
const digits = trimmed.slice(2);
|
||||
if (!/^\d+$/.test(digits)) return 'Must contain only digits after GB';
|
||||
if (digits.length !== 9 && digits.length !== 12) return 'Must be GB followed by 9 or 12 digits';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -207,7 +211,7 @@
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
const newVal = normalisedForm[field] as unknown;
|
||||
const newVal = normalisedForm[field];
|
||||
const oldVal = settings[field];
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
|
||||
patch[field] = newVal;
|
||||
|
||||
@@ -286,7 +286,7 @@
|
||||
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
|
||||
<td class="py-3 text-right">{service.duration_minutes} min</td>
|
||||
<td class="py-3 text-right">
|
||||
{service.usage_count}×{service.last_used_at ? ` (last: ${new Date(service.last_used_at).toLocaleDateString()})` : ''}
|
||||
{service.usage_count}×{service.last_used_at ? ` (last: ${new Date(service.last_used_at).toLocaleDateString('en-GB', { timeZone: 'Europe/London' })})` : ''}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center gap-2">
|
||||
|
||||
@@ -147,8 +147,8 @@
|
||||
campaign_type: c.campaign_type,
|
||||
discount_percent: c.discount_percent,
|
||||
scope: c.scope || 'all_bookings',
|
||||
start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 10) : '',
|
||||
end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 10) : '',
|
||||
start_date: c.start_date ? c.start_date.slice(0, 10) : '',
|
||||
end_date: c.end_date ? c.end_date.slice(0, 10) : '',
|
||||
milestone_type: c.milestone_type || 'per_user_booking_count',
|
||||
milestone_value: c.milestone_value || 0,
|
||||
milestone_unit: c.milestone_unit || 'bookings',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -437,7 +438,7 @@
|
||||
<div class="text-xs text-gray-500">Time</div>
|
||||
<div class="font-medium">
|
||||
{(() => {
|
||||
const date = new SvelteDate(booking.start_time);
|
||||
const date = parseWallClockDate(booking.start_time);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
interface ServiceItem {
|
||||
id: string;
|
||||
@@ -55,7 +56,7 @@
|
||||
let showDenyConfirm = $state(false);
|
||||
|
||||
function formatDateLine1(dateTimeString: string): string {
|
||||
const d = new SvelteDate(dateTimeString);
|
||||
const d = parseWallClockDate(dateTimeString);
|
||||
const dateStr = d.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
@@ -71,7 +72,7 @@
|
||||
}
|
||||
|
||||
function formatDateLine2(dateTimeString: string, durationMinutes: number): string {
|
||||
const d = new SvelteDate(dateTimeString);
|
||||
const d = parseWallClockDate(dateTimeString);
|
||||
const endMinutes = d.getHours() * 60 + d.getMinutes() + durationMinutes;
|
||||
const endH = Math.floor(endMinutes / 60);
|
||||
const endM = endMinutes % 60;
|
||||
|
||||
@@ -752,7 +752,8 @@
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,12 +96,15 @@
|
||||
}
|
||||
|
||||
function isoDateOf(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
|
||||
const from = new SvelteDate(fromISO + 'T00:00:00');
|
||||
const to = new SvelteDate(toISO + 'T00:00:00');
|
||||
const from = new SvelteDate(fromISO + 'T00:00:00Z');
|
||||
const to = new SvelteDate(toISO + 'T00:00:00Z');
|
||||
const first = new SvelteDate(from);
|
||||
const day = first.getDay();
|
||||
const daysToMonday = day === 0 ? -6 : 1 - day;
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
generateAvailableTimeSlots,
|
||||
generateGroupedTimeSlots,
|
||||
formatTime,
|
||||
formatLocalDateTime,
|
||||
calculateEndTime,
|
||||
getDayWithOrdinal,
|
||||
getLondonTodayCalendarDate,
|
||||
parseWallClockDate,
|
||||
type DayHours,
|
||||
type DayAvailability
|
||||
} from '$lib/utils/timeSlots';
|
||||
@@ -35,13 +38,7 @@
|
||||
let { open = $bindable(), booking, onSaved }: Props = $props();
|
||||
|
||||
let saving = $state(false);
|
||||
let placeholder = $state<CalendarDate>(
|
||||
new CalendarDate(
|
||||
new SvelteDate().getFullYear(),
|
||||
new SvelteDate().getMonth() + 1,
|
||||
new SvelteDate().getDate()
|
||||
)
|
||||
);
|
||||
let placeholder = $state<CalendarDate>(getLondonTodayCalendarDate());
|
||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||
let selectedTime = $state<string | null>(null);
|
||||
let workingHours = $state<Record<string, DayHours> | null>(null);
|
||||
@@ -51,17 +48,17 @@
|
||||
let hoursMonthGeneration = $state(0);
|
||||
let userNavigatedCalendar = $state(false);
|
||||
|
||||
let workingHoursCache: Record<string, Record<string, any>> = {};
|
||||
let availableHoursCache: Record<string, Record<string, any>> = {};
|
||||
let workingHoursCache: Record<string, Record<string, DayHours>> = {};
|
||||
let availableHoursCache: Record<string, Record<string, DayAvailability>> = {};
|
||||
let loadingMonths: Record<string, boolean> = {};
|
||||
let loadingMonthKeys = new SvelteSet<string>();
|
||||
let initialLoadDone = $state(false);
|
||||
let rescheduleAutoSelectDone = $state(false);
|
||||
|
||||
const today = new SvelteDate();
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const maxDate = new SvelteDate();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const minDate = today;
|
||||
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
|
||||
maxDate.setMonth(today.month - 1 + 6);
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
maxDate.getMonth() + 1,
|
||||
@@ -300,11 +297,7 @@
|
||||
loadingMonths = {};
|
||||
loadingMonthKeys = new SvelteSet<string>();
|
||||
rescheduleAutoSelectDone = false;
|
||||
placeholder = new CalendarDate(
|
||||
new SvelteDate().getFullYear(),
|
||||
new SvelteDate().getMonth() + 1,
|
||||
new SvelteDate().getDate()
|
||||
);
|
||||
placeholder = getLondonTodayCalendarDate();
|
||||
}
|
||||
|
||||
async function reschedule() {
|
||||
@@ -315,7 +308,7 @@
|
||||
const localDate = selectedDate.toDate(getLocalTimeZone());
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
localDate.setHours(hours, minutes, 0, 0);
|
||||
const newStartTime = localDate.toISOString();
|
||||
const newStartTime = formatLocalDateTime(localDate);
|
||||
if (new Date(newStartTime).getTime() <= Date.now()) {
|
||||
toast.error('Start time must be in the future');
|
||||
return;
|
||||
@@ -365,8 +358,8 @@
|
||||
}
|
||||
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
||||
if (!(key in workingHoursCache)) {
|
||||
workingHoursCache[key] = null as unknown as Record<string, any>;
|
||||
availableHoursCache[key] = null as unknown as Record<string, any>;
|
||||
workingHoursCache[key] = null as unknown as Record<string, DayHours>;
|
||||
availableHoursCache[key] = null as unknown as Record<string, DayAvailability>;
|
||||
loadingMonths[key] = true;
|
||||
}
|
||||
}
|
||||
@@ -397,7 +390,7 @@
|
||||
!rescheduleAutoSelectDone
|
||||
) {
|
||||
rescheduleAutoSelectDone = true;
|
||||
const now = new SvelteDate();
|
||||
const now = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new SvelteDate(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
@@ -410,7 +403,7 @@
|
||||
for (let i = 1; i <= daysToCheck; i++) {
|
||||
const checkDate = new SvelteDate(now);
|
||||
checkDate.setDate(now.getDate() + i);
|
||||
const dateStr = checkDate.toISOString().split('T')[0];
|
||||
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const calDate = new CalendarDate(
|
||||
checkDate.getFullYear(),
|
||||
checkDate.getMonth() + 1,
|
||||
@@ -484,12 +477,12 @@
|
||||
<Card.Root class="mb-4">
|
||||
<Card.Content class="pt-4">
|
||||
<div class="text-sm font-medium">
|
||||
Current: {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
|
||||
Current: {parseWallClockDate(booking.start_time).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})} at {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
})} at {parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -115,7 +117,7 @@
|
||||
|
||||
function getWorkingHoursForDate(dateStr: string): WorkingHourRow | null {
|
||||
if (!dateStr || defaultHours.length === 0) return null;
|
||||
const d = new SvelteDate(dateStr + 'T00:00:00');
|
||||
const d = new SvelteDate(dateStr + 'T00:00:00Z');
|
||||
const jsDay = d.getDay();
|
||||
const weekday = jsDay === 0 ? 6 : jsDay - 1;
|
||||
return defaultHours.find((h) => h.weekday === weekday) ?? null;
|
||||
@@ -265,7 +267,12 @@
|
||||
): string | null {
|
||||
if (!date) return null;
|
||||
const t24 = to24h(hour, minute, period);
|
||||
return `${date}T${t24}:00`;
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
const cal = new CalendarDate(y, m, d);
|
||||
const [h, min] = t24.split(':').map(Number);
|
||||
const dt = cal.toDate('Europe/London');
|
||||
dt.setHours(h, min, 0, 0);
|
||||
return formatLocalDateTime(dt);
|
||||
}
|
||||
|
||||
let formComplete = $derived.by(() => {
|
||||
@@ -403,7 +410,7 @@
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start_time: start.toISOString(),
|
||||
start_time: startIso,
|
||||
duration_minutes: 60,
|
||||
description: `RESERVATION:placeholder:${booking.id}`
|
||||
})
|
||||
@@ -422,7 +429,7 @@
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start_time: start.toISOString(),
|
||||
start_time: startIso,
|
||||
duration_minutes: durationMinutes,
|
||||
description: newDescription.trim()
|
||||
})
|
||||
@@ -572,7 +579,7 @@
|
||||
<div class="truncate font-medium text-gray-900">{b.description || 'Untitled'}</div>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-gray-600">
|
||||
<span>
|
||||
{new SvelteDate(b.start_time).toLocaleDateString('en-GB', {
|
||||
{parseWallClockDate(b.start_time).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
@@ -580,7 +587,7 @@
|
||||
</span>
|
||||
<span class="text-gray-400">·</span>
|
||||
<span>
|
||||
{new SvelteDate(b.start_time).toLocaleTimeString('en-GB', {
|
||||
{parseWallClockDate(b.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
@@ -589,7 +596,7 @@
|
||||
<span class="text-gray-400">–</span>
|
||||
<span>
|
||||
{(() => {
|
||||
const end = new SvelteDate(b.start_time);
|
||||
const end = parseWallClockDate(b.start_time);
|
||||
end.setMinutes(end.getMinutes() + b.duration_minutes);
|
||||
return end.toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
@@ -712,7 +719,7 @@
|
||||
size="sm"
|
||||
class="flex-1 text-xs"
|
||||
onclick={() => {
|
||||
newStartDate = new Date().toISOString().slice(0, 10);
|
||||
newStartDate = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
onStartDateChange();
|
||||
}}
|
||||
>
|
||||
@@ -724,7 +731,7 @@
|
||||
size="sm"
|
||||
class="flex-1 text-xs"
|
||||
onclick={() => {
|
||||
newStartDate = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
||||
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
onStartDateChange();
|
||||
}}
|
||||
>
|
||||
@@ -746,7 +753,7 @@
|
||||
<p class="text-sm text-gray-400">Select a date first</p>
|
||||
{:else if !selectedWorkingHours.isOpen}
|
||||
<p class="text-sm text-red-500">
|
||||
Closed on {new SvelteDate(newStartDate + 'T00:00:00').toLocaleDateString('en-GB', {
|
||||
Closed on {new SvelteDate(newStartDate + 'T00:00:00Z').toLocaleDateString('en-GB', {
|
||||
weekday: 'long'
|
||||
})}
|
||||
</p>
|
||||
@@ -855,7 +862,7 @@
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PatchTestModal from './PatchTestModal.svelte';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PatchTestModal from './PatchTestModal.svelte';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -404,7 +405,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
{(() => {
|
||||
const date = new SvelteDate(booking.start_time);
|
||||
const date = parseWallClockDate(booking.start_time);
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
calculateMiddleWindow,
|
||||
shouldApplyLunchProtection
|
||||
} from '$lib/lunchProtection';
|
||||
import { formatLocalDateTime } from '$lib/utils/timeSlots';
|
||||
|
||||
const RESERVATION_TTL = 15;
|
||||
|
||||
@@ -50,7 +51,7 @@
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch services', err);
|
||||
// Silently handled - services list remains empty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +144,9 @@
|
||||
|
||||
try {
|
||||
const now = new SvelteDate();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
const today = new CalendarDate(y, m, d);
|
||||
|
||||
const response = await fetch(`/api/scheduling/available-hours?start=${today}&end=${today}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
@@ -234,18 +237,11 @@
|
||||
isReserving = true;
|
||||
|
||||
try {
|
||||
const now = new SvelteDate();
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
const start = new SvelteDate(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
hours,
|
||||
minutes,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const startTimeISO = start.toISOString();
|
||||
const start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
|
||||
const startTimeISO = formatLocalDateTime(start);
|
||||
|
||||
const response = await fetch('/api/admin/bookings/reserve', {
|
||||
method: 'POST',
|
||||
@@ -290,8 +286,8 @@
|
||||
}
|
||||
|
||||
function startWalkInCountdown() {
|
||||
if ((window as any).__walkInCountdownInterval) {
|
||||
clearInterval((window as any).__walkInCountdownInterval);
|
||||
if (window.__walkInCountdownInterval) {
|
||||
clearInterval(window.__walkInCountdownInterval);
|
||||
}
|
||||
|
||||
const updateCountdown = () => {
|
||||
@@ -308,8 +304,8 @@
|
||||
reservationId = null;
|
||||
reservationExpiresAt = null;
|
||||
toast.error('Slot released — please re-check availability');
|
||||
if ((window as any).__walkInCountdownInterval) {
|
||||
clearInterval((window as any).__walkInCountdownInterval);
|
||||
if (window.__walkInCountdownInterval) {
|
||||
clearInterval(window.__walkInCountdownInterval);
|
||||
}
|
||||
showCreateModal = false;
|
||||
return;
|
||||
@@ -321,7 +317,7 @@
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
(window as any).__walkInCountdownInterval = setInterval(updateCountdown, 1000);
|
||||
window.__walkInCountdownInterval = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
let availableMinutes = $derived.by(() => {
|
||||
@@ -379,8 +375,8 @@
|
||||
reservationCountdown = '';
|
||||
reservedDuration = 0;
|
||||
reservedStartTime = null;
|
||||
if ((window as any).__walkInCountdownInterval) {
|
||||
clearInterval((window as any).__walkInCountdownInterval);
|
||||
if (window.__walkInCountdownInterval) {
|
||||
clearInterval(window.__walkInCountdownInterval);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { getLocalTimeZone } from '@internationalized/date';
|
||||
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { formatLocalDateTime } from '$lib/utils/timeSlots';
|
||||
|
||||
// UI Components
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
@@ -213,8 +214,8 @@
|
||||
});
|
||||
|
||||
function startCountdown() {
|
||||
if ((window as any).__walkInModalCountdownInterval) {
|
||||
clearInterval((window as any).__walkInModalCountdownInterval);
|
||||
if (window.__walkInModalCountdownInterval) {
|
||||
clearInterval(window.__walkInModalCountdownInterval);
|
||||
}
|
||||
|
||||
const updateCountdown = () => {
|
||||
@@ -229,8 +230,8 @@
|
||||
if (diff <= 0) {
|
||||
reservationCountdown = 'Expired';
|
||||
isReservationExpired = true;
|
||||
if ((window as any).__walkInModalCountdownInterval) {
|
||||
clearInterval((window as any).__walkInModalCountdownInterval);
|
||||
if (window.__walkInModalCountdownInterval) {
|
||||
clearInterval(window.__walkInModalCountdownInterval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -241,7 +242,7 @@
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
(window as any).__walkInModalCountdownInterval = setInterval(updateCountdown, 1000);
|
||||
window.__walkInModalCountdownInterval = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
@@ -299,7 +300,6 @@
|
||||
services = await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch services', err);
|
||||
toast.error('Failed to load services');
|
||||
} finally {
|
||||
loadingServices = false;
|
||||
@@ -461,19 +461,16 @@
|
||||
if (availableStartTime) {
|
||||
// Parse the time from the widget (format: "HH:MM" or "HH:MM:SS")
|
||||
const [hours, minutes] = availableStartTime.split(':').map(Number);
|
||||
const now = new SvelteDate();
|
||||
start = new SvelteDate(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
hours,
|
||||
minutes,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
|
||||
} else {
|
||||
// Fallback: Calculate immediate start time (rounded to next 15 min)
|
||||
const now = new SvelteDate();
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const londonTimeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
const [h, min] = londonTimeStr.split(':').map(Number);
|
||||
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
|
||||
start = new SvelteDate(now);
|
||||
const minutes = start.getMinutes();
|
||||
const remainder = 15 - (minutes % 15);
|
||||
@@ -484,7 +481,7 @@
|
||||
start.setMilliseconds(0);
|
||||
}
|
||||
|
||||
const dateTimeStr = start.toISOString();
|
||||
const dateTimeStr = formatLocalDateTime(start);
|
||||
|
||||
const overrides = [];
|
||||
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
|
||||
@@ -500,11 +497,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
const payload: {
|
||||
user_id: string;
|
||||
start_time: string;
|
||||
service_ids: string[];
|
||||
custom_service_ids: string[];
|
||||
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
|
||||
notes: string | null;
|
||||
} = {
|
||||
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.is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter(s => s.is_custom).map((s) => s.id),
|
||||
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||
notes: notes.trim() || null
|
||||
};
|
||||
@@ -526,11 +530,9 @@
|
||||
onBookingCreated?.();
|
||||
} else {
|
||||
const errorText = await res.text();
|
||||
console.error('Booking creation failed:', errorText);
|
||||
toast.error(`Failed to create booking: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Booking submission error:', err);
|
||||
toast.error('An error occurred while creating booking');
|
||||
} finally {
|
||||
submitting = false;
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
|
||||
// salon app. All customers are physically in the UK and book UK appointment slots. We do NOT
|
||||
// auto-adjust for international timezones — the slot time shown is the actual UK salon time.
|
||||
// Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled manually by
|
||||
// staff adjusting working hours; the app does not need timezone-aware scheduling logic.
|
||||
// Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled automatically:
|
||||
// formatLocalDateTime converts wall-clock time to UTC using the correct DST offset for the
|
||||
// target date (via @internationalized/date's CalendarDate.toDate which applies the target
|
||||
// date's timezone rules, not the current date's). The backend stores all timestamps as
|
||||
// TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at
|
||||
// "10am June 15" stays at 10am BST regardless of when the booking was made.
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
@@ -40,6 +44,7 @@
|
||||
getLunchProtectionForSlots,
|
||||
type TimeSlot
|
||||
} from '$lib/lunchProtection';
|
||||
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
|
||||
|
||||
import type {
|
||||
Service,
|
||||
@@ -52,7 +57,18 @@
|
||||
} from '$lib/types/booking';
|
||||
|
||||
// =============== State Management ===============
|
||||
let currentStep = $state<number>(authStore.isAuthenticated ? 1 : 0);
|
||||
let currentStep = $state<number>(0);
|
||||
let authReady = $state(false);
|
||||
|
||||
// Wait for auth store to finish initializing before deciding which step to show.
|
||||
// This prevents a flash of the login prompt on SSR + hydration — the skeleton
|
||||
// displays while auth checks are pending, then the correct screen appears.
|
||||
$effect(() => {
|
||||
if (authStore.hasLoaded && !authReady) {
|
||||
authReady = true;
|
||||
currentStep = authStore.isAuthenticated ? 1 : 0;
|
||||
}
|
||||
});
|
||||
let selectedServices = $state<Service[]>([]);
|
||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||
let selectedTime = $state<string | null>(null);
|
||||
@@ -198,7 +214,6 @@
|
||||
userDepositsRequired = user.deposits_required ?? 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user deposits status:', err);
|
||||
userDepositsRequired = 0;
|
||||
}
|
||||
}
|
||||
@@ -234,7 +249,6 @@
|
||||
hasActiveBooking = data.bookings && data.bookings.length > 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check active booking status:', err);
|
||||
hasActiveBooking = false;
|
||||
} finally {
|
||||
activeBookingCheckDone = true;
|
||||
@@ -265,7 +279,6 @@
|
||||
paymentMethods = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch payment methods:', err);
|
||||
paymentMethods = [];
|
||||
} finally {
|
||||
paymentMethodsLoading = false;
|
||||
@@ -417,7 +430,7 @@
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
const bookingDate = selectedDate.toDate(getLocalTimeZone());
|
||||
bookingDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = bookingDate.toISOString();
|
||||
const startTimeISO = formatLocalDateTime(bookingDate);
|
||||
const serviceIds = selectedServices.map((s) => s.id);
|
||||
|
||||
const response = await fetch('/api/bookings/reserve', {
|
||||
@@ -533,11 +546,9 @@
|
||||
// Combine: valid first, then grayed out
|
||||
services = [...valid, ...grayedOut];
|
||||
} else {
|
||||
console.error('Failed to fetch services:', response.status);
|
||||
toast.error('Failed to load services');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching services:', err);
|
||||
toast.error('Network error loading services');
|
||||
} finally {
|
||||
servicesLoading = false;
|
||||
@@ -569,14 +580,10 @@
|
||||
> = {};
|
||||
|
||||
// Initialize date boundaries
|
||||
const today = new SvelteDate();
|
||||
const tomorrow = new SvelteDate(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const maxDate = new SvelteDate();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
|
||||
// Create CalendarDate objects
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const minDate = today;
|
||||
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
|
||||
maxDate.setMonth(today.month - 1 + 6);
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
maxDate.getMonth() + 1,
|
||||
@@ -609,14 +616,8 @@
|
||||
}
|
||||
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
||||
if (!(key in workingHoursCache)) {
|
||||
workingHoursCache[key] = null as unknown as Record<
|
||||
string,
|
||||
{ isOpen: boolean; startTime: string; endTime: string }
|
||||
>;
|
||||
availableHoursCache[key] = null as unknown as Record<
|
||||
string,
|
||||
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||
>;
|
||||
workingHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; startTime: string; endTime: string }>;
|
||||
availableHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>;
|
||||
loadingMonths[key] = true;
|
||||
}
|
||||
}
|
||||
@@ -646,7 +647,7 @@
|
||||
) {
|
||||
bookingFlowAutoSelectDone = true;
|
||||
|
||||
const currentDate = new SvelteDate();
|
||||
const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new SvelteDate(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
@@ -661,7 +662,7 @@
|
||||
for (let i = 1; i <= daysToCheck; i++) {
|
||||
const nextDate = new SvelteDate(currentDate);
|
||||
nextDate.setDate(currentDate.getDate() + i);
|
||||
const dateStr = nextDate.toISOString().split('T')[0];
|
||||
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
|
||||
if (workingHours[dateStr]?.isOpen) {
|
||||
const calDate = new CalendarDate(
|
||||
@@ -679,15 +680,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
const tomorrow = new SvelteDate();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
selectedDate = new CalendarDate(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth() + 1,
|
||||
tomorrow.getDate()
|
||||
const tomorrowCal = getLondonTodayCalendarDate();
|
||||
const tomorrowDate = new CalendarDate(
|
||||
tomorrowCal.year,
|
||||
tomorrowCal.month,
|
||||
tomorrowCal.day + 1
|
||||
);
|
||||
selectedDate = tomorrowDate;
|
||||
if (!userNavigatedCalendar) {
|
||||
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
||||
placeholder = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -752,7 +753,6 @@
|
||||
workingHours = { ...workingHours, ...whMap };
|
||||
availableHours = { ...availableHours, ...ahMap };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hours:', error);
|
||||
// Clean up loadingMonths for the range
|
||||
for (let i = 0; i < months; i++) {
|
||||
let mYear = startDate.year;
|
||||
@@ -853,7 +853,6 @@
|
||||
availableHoursCache[monthKey] = availableHoursMap;
|
||||
availableHours = { ...availableHours, ...availableHoursMap };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hours:', error);
|
||||
if (!selectedDate) {
|
||||
selectedDate = minDate;
|
||||
}
|
||||
@@ -911,8 +910,10 @@
|
||||
}
|
||||
|
||||
const slots: string[] = [];
|
||||
const now = new SvelteDate();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const now = new Date();
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
||||
const isToday = date.compare(today) === 0;
|
||||
|
||||
for (const slot of dayAvailableHours.slots) {
|
||||
@@ -923,7 +924,7 @@
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const currentMinutes = londonHours * 60 + londonMinutes;
|
||||
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
@@ -979,12 +980,14 @@
|
||||
let startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
const now = new SvelteDate();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(today) === 0;
|
||||
const todayCal = getLondonTodayCalendarDate();
|
||||
const now = new Date();
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
||||
const isToday = date.compare(todayCal) === 0;
|
||||
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const currentMinutes = londonHours * 60 + londonMinutes;
|
||||
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
@@ -1281,13 +1284,17 @@
|
||||
);
|
||||
|
||||
let depositRequired = $derived(calculateDepositRequired());
|
||||
let totalSteps = $derived(depositRequired ? 5 : 4);
|
||||
let totalSteps = $derived(authStore.isAuthenticated ? 4 : 5);
|
||||
// StepIndicator uses displayNumber = startAt + index. currentStep aligns with displayNumber,
|
||||
// not the array index. For auth: startAt=1, totalSteps=4 → last displayNumber=4.
|
||||
// For guest: startAt=0, totalSteps=5 → last displayNumber=4. Always evaluates to 4.
|
||||
let finalStep = $derived(totalSteps - 1 + (authStore.isAuthenticated ? 1 : 0));
|
||||
let stepLabels = $derived(
|
||||
authStore.isAuthenticated
|
||||
? depositRequired
|
||||
? ['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
|
||||
: ['Service', 'Date & Time', 'Details', 'Confirmation']
|
||||
: ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
|
||||
? ['Service', 'Date & Time', 'Details', 'Payment']
|
||||
: userDepositsRequired > 0
|
||||
? ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment']
|
||||
: ['Welcome', 'Service', 'Date & Time', 'Details', 'Confirmation']
|
||||
);
|
||||
|
||||
// =============== Navigation ===============
|
||||
@@ -1308,24 +1315,24 @@
|
||||
if (!reserved) return;
|
||||
}
|
||||
|
||||
// Step 3 -> Step 4 (if deposit required) or Step 4 (confirmation, if no deposit)
|
||||
// Step 3 -> Final step (Payment if deposit required, else submit booking)
|
||||
if (currentStep === 3) {
|
||||
if (calculateDepositRequired()) {
|
||||
currentStep = 4;
|
||||
currentStep = finalStep;
|
||||
} else {
|
||||
await submitAndProceed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: if deposit required, this is payment step -> submit booking -> step 5
|
||||
// Step 4: if no deposit, this is confirmation step -> nothing
|
||||
if (currentStep === 4 && calculateDepositRequired()) {
|
||||
await submitAndProceed();
|
||||
// Final step with deposit: user must pay before booking is created.
|
||||
// Payment is handled by processPayment(), not nextStep().
|
||||
// This guards against manual increment from the payment step.
|
||||
if (currentStep === finalStep && calculateDepositRequired()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep < (depositRequired ? 5 : 4)) {
|
||||
if (currentStep < finalStep) {
|
||||
currentStep++;
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@@ -1364,7 +1371,7 @@
|
||||
const [hours, minutes] = selectedTime.split(':').map(Number);
|
||||
const bookingDate = selectedDate.toDate(getLocalTimeZone());
|
||||
bookingDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = bookingDate.toISOString();
|
||||
const startTimeISO = formatLocalDateTime(bookingDate);
|
||||
|
||||
const serviceIds = selectedServices.map((s) => s.id);
|
||||
|
||||
@@ -1436,7 +1443,7 @@
|
||||
total_amount: booking.total_amount || getTotalPrice(),
|
||||
duration_minutes: booking.duration_minutes || getTotalDuration()
|
||||
};
|
||||
currentStep = depositRequired ? 5 : 4;
|
||||
currentStep = finalStep;
|
||||
fetchDiscountPreview();
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@@ -1478,10 +1485,8 @@
|
||||
} else {
|
||||
toast.error('Failed to submit booking: ' + errorMessage);
|
||||
}
|
||||
console.error('Booking submission failed:', response.status, errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Booking submission error:', error);
|
||||
toast.error('Network error. Please check your connection and try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
@@ -1532,6 +1537,34 @@
|
||||
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
||||
</div>
|
||||
|
||||
{#if !authReady}
|
||||
|
||||
<div class="mb-6 flex items-center justify-center gap-2">
|
||||
{#each [1, 2, 3, 4] as _}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
{#if _ < 4}
|
||||
<div class="mx-1 h-0.5 w-8 animate-pulse rounded bg-gray-200" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="h-7 w-40 animate-pulse rounded bg-gray-200" />
|
||||
<div class="mt-2 h-4 w-64 animate-pulse rounded bg-gray-200" />
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
|
||||
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
|
||||
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{:else}
|
||||
|
||||
<StepIndicator
|
||||
{currentStep}
|
||||
steps={stepLabels}
|
||||
@@ -1752,6 +1785,12 @@
|
||||
|
||||
<!-- Step 3: Customer Details -->
|
||||
{#if currentStep === 3}
|
||||
<div class="mb-6 text-center">
|
||||
<h2 class="font-['Playfair_Display'] text-2xl font-bold">Almost There</h2>
|
||||
{#if !authStore.isAuthenticated}
|
||||
<p class="mt-1 text-gray-500">Just a couple more details</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Your Details</Card.Title>
|
||||
@@ -1765,7 +1804,7 @@
|
||||
{:else}
|
||||
<div class="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<p class="text-blue-700">
|
||||
Your slot is reserved for {reservationCountdown} — complete your booking before time expires
|
||||
Your slot will be held for {reservationCountdown} — complete your booking before time expires
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1903,119 +1942,8 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 4: Deposit Payment (only shown if deposit required) -->
|
||||
{#if currentStep === 4 && depositRequired}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Pay Your Deposit</Card.Title>
|
||||
<Card.Description>
|
||||
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
|
||||
your appointment.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||
{:else if paymentMethods.length > 0}
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
|
||||
<div class="space-y-3">
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
||||
method.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
|
||||
>
|
||||
{method.brand}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {method.last4}</span>
|
||||
<span class="ml-2 text-gray-500">
|
||||
{formatCardExpiry(method.expiry_month, method.expiry_year)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
|
||||
onclick={() => {
|
||||
selectedPaymentMethod = method.id;
|
||||
showNewCardForm = false;
|
||||
}}
|
||||
>
|
||||
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showNewCardForm}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mb-6"
|
||||
onclick={() => {
|
||||
showNewCardForm = true;
|
||||
selectedPaymentMethod = null;
|
||||
}}
|
||||
>
|
||||
+ Add new card
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showNewCardForm || !authStore.isAuthenticated}
|
||||
<CardInput
|
||||
bind:cardNumber={newCardNumber}
|
||||
bind:cardExpiry={newCardExpiry}
|
||||
bind:cardCVC={newCardCVC}
|
||||
disabled={isProcessingPayment}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="ghost" onclick={prevStep}>Back</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment ? 'Processing...' : `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Step 5: Confirmation (or Step 4 if no deposit required) -->
|
||||
{#if currentStep === 5 || (currentStep === 4 && !depositRequired)}
|
||||
<!-- Step 4: Payment & Confirmation (final step) -->
|
||||
{#if currentStep === finalStep}
|
||||
{#if confirmedBooking}
|
||||
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
|
||||
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
|
||||
@@ -2209,14 +2137,111 @@
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
{:else if depositRequired}
|
||||
<Card.Root>
|
||||
<Card.Content class="flex items-center justify-center p-12">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
<p class="text-gray-600">Confirming your booking...</p>
|
||||
<Card.Header>
|
||||
<Card.Title>Pay Your Deposit</Card.Title>
|
||||
<Card.Description>
|
||||
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
|
||||
your appointment.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||
{:else if paymentMethods.length > 0}
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
|
||||
<div class="space-y-3">
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
||||
method.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
|
||||
>
|
||||
{method.brand}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {method.last4}</span>
|
||||
<span class="ml-2 text-gray-500">
|
||||
{formatCardExpiry(method.expiry_month, method.expiry_year)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
|
||||
onclick={() => {
|
||||
selectedPaymentMethod = method.id;
|
||||
showNewCardForm = false;
|
||||
}}
|
||||
>
|
||||
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showNewCardForm}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mb-6"
|
||||
onclick={() => {
|
||||
showNewCardForm = true;
|
||||
selectedPaymentMethod = null;
|
||||
}}
|
||||
>
|
||||
+ Add new card
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showNewCardForm || !authStore.isAuthenticated}
|
||||
<CardInput
|
||||
bind:cardNumber={newCardNumber}
|
||||
bind:cardExpiry={newCardExpiry}
|
||||
bind:cardCVC={newCardCVC}
|
||||
disabled={isProcessingPayment}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="ghost" onclick={prevStep}>Back</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment ? 'Processing...' : `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -2255,4 +2280,5 @@
|
||||
canSaveCards={authStore.isAuthenticated}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
const today = new SvelteDate();
|
||||
const closing = new SvelteDate(
|
||||
today.getFullYear(),
|
||||
today.getMonth() + 1,
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
ch,
|
||||
cm,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
@@ -20,6 +21,7 @@
|
||||
timeToMinutes
|
||||
} from '$lib/lunchProtection';
|
||||
import { formatDuration, formatDateISO } from '$lib/utils/format';
|
||||
import { formatLocalDateTime } from '$lib/utils/timeSlots';
|
||||
|
||||
interface Props {
|
||||
openBookingModal: (bookingId: string) => void;
|
||||
@@ -104,10 +106,11 @@
|
||||
let startSelectValue = $derived(`${startHour}:${startMinute}:${startPeriod}`);
|
||||
let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`);
|
||||
|
||||
const today = $derived(formatDateISO(new SvelteDate()));
|
||||
const today = $derived(new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }));
|
||||
|
||||
const weekStartStr = $derived.by(() => {
|
||||
const d = new SvelteDate();
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const d = new SvelteDate(londonDateStr + 'T00:00:00Z');
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? 6 : day - 1;
|
||||
d.setDate(d.getDate() - diff);
|
||||
@@ -115,7 +118,7 @@
|
||||
});
|
||||
|
||||
const weekEndStr = $derived.by(() => {
|
||||
const d = new SvelteDate(weekStartStr);
|
||||
const d = new SvelteDate(weekStartStr + 'T00:00:00Z');
|
||||
d.setDate(d.getDate() + 6);
|
||||
return formatDateISO(d);
|
||||
});
|
||||
@@ -490,7 +493,12 @@
|
||||
): string | null {
|
||||
if (!date) return null;
|
||||
const t24 = to24h(hour, minute, period);
|
||||
return `${date}T${t24}:00`;
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
const cal = new CalendarDate(y, m, d);
|
||||
const [h, min] = t24.split(':').map(Number);
|
||||
const dt = cal.toDate('Europe/London');
|
||||
dt.setHours(h, min, 0, 0);
|
||||
return formatLocalDateTime(dt);
|
||||
}
|
||||
|
||||
let canCreate = $derived.by(() => !hasOverlap && !checkingOverlap);
|
||||
@@ -513,7 +521,7 @@
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start_time: start.toISOString(),
|
||||
start_time: startIso,
|
||||
duration_minutes: durationMinutes,
|
||||
description: newDescription.trim() || 'break'
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { formatLocalDateTime } from '$lib/utils/timeSlots';
|
||||
|
||||
type TodayAppointment = {
|
||||
id: string;
|
||||
@@ -40,10 +41,7 @@
|
||||
let prevJson = $state('');
|
||||
let initialized = $state(false);
|
||||
|
||||
const today = $derived.by(() => {
|
||||
const d = new SvelteDate();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
});
|
||||
const today = $derived(new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }));
|
||||
|
||||
function timeToMinutes(time: string): number {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
@@ -51,7 +49,8 @@
|
||||
}
|
||||
|
||||
async function findLastWorkingDayClose(): Promise<{ cutoff: string; spansClosed: boolean }> {
|
||||
const now = new SvelteDate();
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const now = new SvelteDate(londonDateStr + 'T00:00:00Z');
|
||||
let spansClosed = false;
|
||||
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
@@ -77,7 +76,7 @@
|
||||
const [h, m] = closeTime.split(':').map(Number);
|
||||
const closeDate = new SvelteDate(d);
|
||||
closeDate.setHours(h, m, 0, 0);
|
||||
return { cutoff: closeDate.toISOString(), spansClosed };
|
||||
return { cutoff: formatLocalDateTime(closeDate), spansClosed };
|
||||
} else {
|
||||
spansClosed = true;
|
||||
}
|
||||
@@ -87,7 +86,7 @@
|
||||
const fallback = new SvelteDate(now);
|
||||
fallback.setDate(fallback.getDate() - 1);
|
||||
fallback.setHours(17, 0, 0, 0);
|
||||
return { cutoff: fallback.toISOString(), spansClosed };
|
||||
return { cutoff: formatLocalDateTime(fallback), spansClosed };
|
||||
}
|
||||
|
||||
async function fetchTodayStats() {
|
||||
@@ -153,7 +152,7 @@
|
||||
}
|
||||
|
||||
const { cutoff, spansClosed } = await findLastWorkingDayClose();
|
||||
const nowISO = new SvelteDate().toISOString();
|
||||
const nowISO = formatLocalDateTime(new SvelteDate());
|
||||
let bookingsMade = 0;
|
||||
try {
|
||||
const bmRes = await fetch(
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
type Props = WithElementRef<
|
||||
Omit<HTMLInputAttributes, 'type'> &
|
||||
({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
|
||||
>;
|
||||
> & { error?: string };
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
type,
|
||||
files = $bindable(),
|
||||
error = '',
|
||||
class: className,
|
||||
'data-slot': dataSlot = 'input',
|
||||
...restProps
|
||||
@@ -33,6 +34,7 @@
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
aria-invalid={!!error}
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
@@ -47,6 +49,7 @@
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
aria-invalid={!!error}
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -231,8 +231,7 @@
|
||||
if (!map || !isLoaded) return;
|
||||
if (map.getLayer(layerId)) {
|
||||
for (const [key, value] of Object.entries(mergedPaint)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
map.setPaintProperty(layerId, key as any, value);
|
||||
map.setPaintProperty(layerId, key as keyof MapArcLinePaint, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -102,8 +102,7 @@ class AuthStore {
|
||||
const payload = token.split('.')[1];
|
||||
const decoded = JSON.parse(atob(payload));
|
||||
return decoded;
|
||||
} catch (e) {
|
||||
console.error('Failed to decode token:', e);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -149,8 +148,7 @@ class AuthStore {
|
||||
|
||||
const userData = await response.json();
|
||||
this.user = userData;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user profile:', error);
|
||||
} catch {
|
||||
this.clearAuth();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Service {
|
||||
patch_test_duration_hours: number;
|
||||
minimum_age_required: number;
|
||||
patch_test_status?: 'ok' | 'required' | 'expired';
|
||||
is_custom?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomService extends Service {
|
||||
|
||||
@@ -32,12 +32,14 @@ export function formatDateTime(date: Date | string): string {
|
||||
const dateStr = d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
const timeStr = d.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
hour12: true,
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
}
|
||||
@@ -47,8 +49,8 @@ export function formatDateTime(date: Date | string): string {
|
||||
*
|
||||
* Example: new Date(2026, 4, 29) → "2026-05-29"
|
||||
*/
|
||||
export function formatDateISO(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
export function formatDateISO(d: Date, timeZone = 'Europe/London'): string {
|
||||
return d.toLocaleDateString('en-CA', { timeZone });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,7 +63,8 @@ export function formatDate(date: Date | string): string {
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +78,8 @@ export function formatTime(date: Date | string): string {
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
hour12: true,
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,9 +92,14 @@ export function calculateAge(dateOfBirth: string | undefined | null): number | n
|
||||
const dob = new Date(dateOfBirth);
|
||||
if (isNaN(dob.getTime())) return null;
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dob.getFullYear();
|
||||
const monthDiff = today.getMonth() - dob.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
|
||||
|
||||
// Get London date components to ensure correct DST-safe age calculation
|
||||
const [dobY, dobM, dobD] = dob.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }).split('-').map(Number);
|
||||
const [todayY, todayM, todayD] = today.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }).split('-').map(Number);
|
||||
|
||||
let age = todayY - dobY;
|
||||
const monthDiff = todayM - dobM;
|
||||
if (monthDiff < 0 || (monthDiff === 0 && todayD < dobD)) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
|
||||
@@ -6,6 +6,72 @@ import {
|
||||
type LunchProtectionResult
|
||||
} from '$lib/lunchProtection';
|
||||
|
||||
/**
|
||||
* Returns the UTC datetime string with explicit +00:00 offset, suitable for
|
||||
* sending to the backend for scheduling/booking purposes.
|
||||
* This is functionally equivalent to `Date.toISOString()` except the suffix
|
||||
* is `+00:00` instead of `Z`. Both are valid RFC3339, but Go's `time.Time`
|
||||
* JSON unmarshalling prefers the explicit offset form when constructing
|
||||
* wall-clock timestamps that should not be shifted by the backend's timezone.
|
||||
* The DST correctness of the resulting datetime comes from the CalendarDate
|
||||
* / getLocalTimeZone() path used to construct the Date object — not from
|
||||
* this formatting function.
|
||||
*/
|
||||
export function formatLocalDateTime(date: Date): string {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
const h = String(date.getUTCHours()).padStart(2, '0');
|
||||
const min = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const s = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}T${h}:${min}:${s}+00:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a UTC ISO datetime string (e.g. "2026-06-15T09:00:00Z" or
|
||||
* "2026-06-15T09:00:00+00:00") from the backend and returns a Date
|
||||
* whose getHours()/getMinutes() in the browser's local timezone
|
||||
* reflect the local wall-clock time.
|
||||
*
|
||||
* The backend stores all times as UTC wall-clock values. This function
|
||||
* uses the standard Date parser which correctly interprets the ISO
|
||||
* string as UTC and applies the browser timezone for get*() accessors.
|
||||
* Example: "2026-06-15T09:00:00Z" → getHours() returns 10 in BST.
|
||||
*/
|
||||
export function parseWallClockDate(iso: string): Date {
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a UTC ISO datetime string as a human-readable time (e.g. "10:00").
|
||||
* Always shows the wall-clock time, NOT shifted by timezone.
|
||||
*/
|
||||
export function formatWallClockTime(iso: string): string {
|
||||
const d = parseWallClockDate(iso);
|
||||
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a UTC ISO datetime string as a human-readable date (e.g. "Monday, 15 June 2026").
|
||||
* Always shows the wall-clock date, NOT shifted by timezone.
|
||||
*/
|
||||
export function formatWallClockDate(iso: string): string {
|
||||
const d = parseWallClockDate(iso);
|
||||
return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CalendarDate representing today's date in the Europe/London timezone.
|
||||
* This ensures "is today" comparisons are correct even during BST (British Summer Time)
|
||||
* when there is a window between 00:00-01:00 BST where the UTC date differs from the
|
||||
* London date. Avoids using browser-local time which can be wrong in that window.
|
||||
*/
|
||||
export function getLondonTodayCalendarDate(): CalendarDate {
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
return new CalendarDate(y, m, d);
|
||||
}
|
||||
|
||||
export interface TimeSlotGroup {
|
||||
type: 'available' | 'unavailable';
|
||||
startTime: string;
|
||||
@@ -55,7 +121,8 @@ export function calculatePreviousTime(time: string): string {
|
||||
|
||||
export function normalizeTime(time: string): string {
|
||||
const parts = time.split(':');
|
||||
return `${parts[0]}:${parts[1]}`;
|
||||
if (parts.length < 2) return time.padStart(5, '0');
|
||||
return `${parts[0].padStart(2, '0')}:${parts[1].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function getDayWithOrdinal(date: CalendarDate): string {
|
||||
@@ -113,19 +180,11 @@ export function generateAvailableTimeSlots(
|
||||
const dayWH = workingHours[dateStr];
|
||||
const dayAH = availableHours[dateStr];
|
||||
if (!dayWH?.isOpen || !dayAH?.slots) {
|
||||
console.log('[DEBUG] generateAvailableTimeSlots - early return', {
|
||||
dateStr,
|
||||
hasDayWH: !!dayWH,
|
||||
dayWH_isOpen: dayWH?.isOpen,
|
||||
hasDayAH: !!dayAH,
|
||||
dayAH_slots: dayAH?.slots?.length
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const slots: string[] = [];
|
||||
const now = new SvelteDate();
|
||||
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const todayCal = getLondonTodayCalendarDate();
|
||||
const isToday = date.compare(todayCal) === 0;
|
||||
|
||||
for (const slot of dayAH.slots) {
|
||||
@@ -135,8 +194,11 @@ export function generateAvailableTimeSlots(
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
let minimumStart = Math.ceil((currentMinutes + 15) / 15) * 15;
|
||||
const now = new Date();
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
||||
const currentMinutes = londonHours * 60 + londonMinutes;
|
||||
let minimumStart = Math.ceil((currentMinutes + 60) / 15) * 15;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStart);
|
||||
}
|
||||
|
||||
@@ -150,16 +212,6 @@ export function generateAvailableTimeSlots(
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[DEBUG] generateAvailableTimeSlots result', {
|
||||
dateStr,
|
||||
duration,
|
||||
dayWH_startTime: dayWH.startTime,
|
||||
dayWH_endTime: dayWH.endTime,
|
||||
dayAH_slots_raw: dayAH.slots,
|
||||
isToday,
|
||||
generatedSlotsCount: slots.length,
|
||||
generatedSlots: slots.slice(0, 20) + (slots.length > 20 ? `... (${slots.length} total)` : '')
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
@@ -171,35 +223,26 @@ export function generateGroupedTimeSlots(
|
||||
protection: Map<string, LunchProtectionResult>
|
||||
): TimeSlotGroup[] {
|
||||
if (!workingHours) {
|
||||
console.log('[DEBUG] generateGroupedTimeSlots - no workingHours');
|
||||
return [];
|
||||
}
|
||||
const dateStr = date.toString();
|
||||
const dayWH = workingHours[dateStr];
|
||||
if (!dayWH || !dayWH.isOpen) {
|
||||
console.log('[DEBUG] generateGroupedTimeSlots - day not open', { dateStr, dayWH });
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('[DEBUG] generateGroupedTimeSlots - inputs', {
|
||||
dateStr,
|
||||
duration,
|
||||
startTime: dayWH.startTime,
|
||||
endTime: dayWH.endTime,
|
||||
isOpen: dayWH.isOpen,
|
||||
source: (dayWH as any).source
|
||||
});
|
||||
|
||||
const grouped: TimeSlotGroup[] = [];
|
||||
const [startHour, startMinute] = dayWH.startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = dayWH.endTime.split(':').map(Number);
|
||||
let startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
const now = new SvelteDate();
|
||||
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const todayCal = getLondonTodayCalendarDate();
|
||||
if (date.compare(todayCal) === 0) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const now = new Date();
|
||||
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
||||
const currentMinutes = londonHours * 60 + londonMinutes;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, Math.ceil((currentMinutes + 15) / 15) * 15);
|
||||
}
|
||||
|
||||
@@ -211,14 +254,6 @@ export function generateGroupedTimeSlots(
|
||||
protection
|
||||
);
|
||||
|
||||
console.log('[DEBUG] generateGroupedTimeSlots - availableSlots', {
|
||||
availableSlotsCount: availableSlots.length,
|
||||
availableSlots: availableSlots.slice(0, 30) + (availableSlots.length > 30 ? `... (${availableSlots.length} total)` : ''),
|
||||
dayRange: `${dayWH.startTime}-${dayWH.endTime}`,
|
||||
startTotalMinutes,
|
||||
endTotalMinutes
|
||||
});
|
||||
|
||||
let currentUnavailableStart: string | null = null;
|
||||
let lastAvailableEndTime: string | null = null;
|
||||
|
||||
@@ -266,12 +301,5 @@ export function generateGroupedTimeSlots(
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[DEBUG] generateGroupedTimeSlots - final result', {
|
||||
availableCount: grouped.filter(s => s.type === 'available').length,
|
||||
unavailableCount: grouped.filter(s => s.type === 'unavailable').length,
|
||||
availableTimes: grouped.filter(s => s.type === 'available').map(s => s.startTime + '-' + s.endTime).slice(0, 20),
|
||||
unavailableRanges: grouped.filter(s => s.type === 'unavailable').map(s => s.startTime + '-' + s.endTime)
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
@@ -1009,7 +1009,7 @@
|
||||
|
||||
loadingUpcoming = true;
|
||||
try {
|
||||
const today = new SvelteDate().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
|
||||
// Fetch more items (e.g. 10) to ensure we find upcoming ones even if the first few are past
|
||||
const response = await fetch(`/api/bookings?start_date=${today}&per_page=10&page=1`, {
|
||||
@@ -1053,7 +1053,7 @@
|
||||
|
||||
loadingPast = true;
|
||||
try {
|
||||
const today = new SvelteDate().toISOString().split('T')[0];
|
||||
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -1071,6 +1071,14 @@
|
||||
const data = await response.json();
|
||||
let bookings = data.bookings || [];
|
||||
|
||||
// Only include bookings that have actually finished (endTime <= now)
|
||||
const now = new SvelteDate();
|
||||
bookings = bookings.filter((b: Booking) => {
|
||||
const startTime = new SvelteDate(b.start_time);
|
||||
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||
return endTime <= now;
|
||||
});
|
||||
|
||||
// FIX: Manually calculate amount_due for the list
|
||||
// The list API often returns 0 for amount_due/amount_paid,
|
||||
// so we derive it from total_amount.
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (pageState === 'authorized' && !weekStart) {
|
||||
const today = new SvelteDate();
|
||||
const today = getLondonToday();
|
||||
const dayOfWeek = today.getDay();
|
||||
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const monday = new SvelteDate(today);
|
||||
@@ -97,6 +97,30 @@
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Return the current London date as a Date set to midnight in the local timezone.
|
||||
* Uses Intl.DateTimeFormat with Europe/London to handle BST/GMT correctly. */
|
||||
function getLondonToday(): Date {
|
||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
return new Date(dateStr + 'T00:00:00');
|
||||
}
|
||||
|
||||
/** Return the current time-of-day in London as minutes since midnight, using
|
||||
* Intl.DateTimeFormat with Europe/London so the current-time blue line is
|
||||
* positioned correctly regardless of the browser's system timezone. */
|
||||
function getLondonNowMinutes(): number {
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const [h, m] = timeStr.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** Convert a UTC ISO timestamp to a London-date YYYY-MM-DD key.
|
||||
* Uses Intl.DateTimeFormat with Europe/London timezone so that bookings
|
||||
* at 23:30 UTC (00:30 BST next day) are grouped under the correct
|
||||
* London date column rather than the UTC date. */
|
||||
function getLondonDateKey(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
}
|
||||
|
||||
function formatWeekLabel(start: Date): string {
|
||||
const end = new SvelteDate(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
@@ -289,10 +313,10 @@
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
const today = new Date();
|
||||
const today = getLondonToday();
|
||||
const dayOfWeek = today.getDay();
|
||||
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const monday = new Date(today);
|
||||
const monday = new SvelteDate(today);
|
||||
monday.setDate(monday.getDate() - diff);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
weekStart = monday;
|
||||
@@ -310,15 +334,17 @@
|
||||
const HEADER_HEIGHT = 48;
|
||||
const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const today = new SvelteDate();
|
||||
const today = getLondonToday();
|
||||
const todayStr = formatDate(today);
|
||||
|
||||
// -- Derived State --
|
||||
|
||||
// Group bookings by date string for O(1) lookup in the loop
|
||||
// Group bookings by London-date key for O(1) lookup in the loop.
|
||||
// Using London date (not UTC date) ensures that bookings crossing midnight
|
||||
// during BST (e.g. 23:30 UTC → 00:30 BST next day) appear in the correct column.
|
||||
const bookingsByDate = $derived(
|
||||
bookings.reduce((acc, b) => {
|
||||
const dateKey = b.start_time.slice(0, 10); // YYYY-MM-DD
|
||||
const dateKey = getLondonDateKey(b.start_time);
|
||||
if (!acc.has(dateKey)) acc.set(dateKey, []);
|
||||
acc.get(dateKey)!.push(b);
|
||||
return acc;
|
||||
@@ -327,7 +353,7 @@
|
||||
|
||||
const blockersByDate = $derived(
|
||||
blockers.reduce((acc, b) => {
|
||||
const dateKey = b.start_time.slice(0, 10); // YYYY-MM-DD
|
||||
const dateKey = getLondonDateKey(b.start_time);
|
||||
if (!acc.has(dateKey)) acc.set(dateKey, []);
|
||||
acc.get(dateKey)!.push(b);
|
||||
return acc;
|
||||
@@ -394,12 +420,11 @@
|
||||
})()
|
||||
);
|
||||
|
||||
let nowMinutes = $state(today.getHours() * 60 + today.getMinutes());
|
||||
let nowMinutes = $state(getLondonNowMinutes());
|
||||
$effect(() => {
|
||||
if (pageState !== 'authorized') return;
|
||||
const tickId = setInterval(() => {
|
||||
const n = new SvelteDate();
|
||||
nowMinutes = n.getHours() * 60 + n.getMinutes();
|
||||
nowMinutes = getLondonNowMinutes();
|
||||
}, 30_000);
|
||||
return () => clearInterval(tickId);
|
||||
});
|
||||
|
||||
@@ -260,18 +260,19 @@
|
||||
function fmtDate(dateStr: string): string {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'Europe/London' });
|
||||
}
|
||||
|
||||
function fmtDateTime(dateStr: string): string {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
return d.toLocaleString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/London'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -464,7 +465,7 @@
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `gdpr-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.download = `gdpr-export-${new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -487,8 +487,7 @@
|
||||
bind:value={formData.dateOfBirth}
|
||||
onblur={() => validateAge(formData.dateOfBirth)}
|
||||
max={new SvelteDate(new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16))
|
||||
.toISOString()
|
||||
.split('T')[0]}
|
||||
.toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
|
||||
required
|
||||
/>
|
||||
{#if validationErrors.dateOfBirth}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
async function fetchBookings() {
|
||||
loading = true;
|
||||
try {
|
||||
const today = new SvelteDate().toISOString().split('T')[0];
|
||||
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const response = await fetch(`/api/bookings?start_date=${today}&per_page=50&page=1`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
Reference in New Issue
Block a user