Fix time picker, WIP date picker
This commit is contained in:
@@ -85,7 +85,6 @@
|
||||
let defaultHours = $state<WorkingHourRow[]>([]);
|
||||
let defaultHoursIsLoading = $state(true);
|
||||
|
||||
/** Format time from HH:MM:SS to 12-hour format */
|
||||
/** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */
|
||||
function formatTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/routes/api/[...path]/+server.ts
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -6,7 +5,11 @@ const BACKEND_URL =
|
||||
import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
|
||||
|
||||
async function proxyRequest(request: Request, path: string) {
|
||||
const url = `${BACKEND_URL}/api/${path}`;
|
||||
const incomingUrl = new URL(request.url);
|
||||
|
||||
const queryString = incomingUrl.search;
|
||||
|
||||
const url = `${BACKEND_URL}/api/${path}${queryString}`;
|
||||
|
||||
try {
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import Calendar from '$lib/components/ui/calendar/calendar.svelte';
|
||||
import { CalendarDate, getLocalTimeZone } from '@internationalized/date';
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
|
||||
// Booking state
|
||||
let currentStep = $state<number>(1);
|
||||
let selectedServices = $state<any[]>([]);
|
||||
let selectedDate = $state<CalendarDate | undefined>(new CalendarDate(2025, 6, 12));
|
||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||
let selectedTime = $state<string | null>(null);
|
||||
let customerInfo = $state({
|
||||
firstName: '',
|
||||
@@ -81,33 +81,246 @@
|
||||
}
|
||||
];
|
||||
|
||||
// Mock unavailable dates
|
||||
const bookedDates = Array.from({ length: 5 }, (_, i) => new CalendarDate(2025, 6, 17 + i));
|
||||
// Working hours state
|
||||
let workingHours = $state<Record<
|
||||
string,
|
||||
{ isOpen: boolean; startTime: string; endTime: string }
|
||||
> | null>(null);
|
||||
let loadingWorkingHours = $state<boolean>(false);
|
||||
const workingHoursCache = new Map<
|
||||
string,
|
||||
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||
>();
|
||||
|
||||
function generateTimeSlots(duration: number) {
|
||||
const slots = [];
|
||||
const startHour = 9;
|
||||
const endHour = 17;
|
||||
// Initialize date boundaries
|
||||
const today = new Date();
|
||||
const maxDate = new Date();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
|
||||
for (let hour = startHour; hour < endHour; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
// Changed to 15-minute intervals
|
||||
const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
// Create CalendarDate objects directly without toCalendar conversion
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
maxDate.getMonth() + 1,
|
||||
maxDate.getDate()
|
||||
);
|
||||
let placeholder = $state<CalendarDate>(minDate);
|
||||
$effect(() => {
|
||||
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||
if (!workingHoursCache.has(monthKey)) {
|
||||
fetchWorkingHoursForMonth(placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if there's enough time before closing
|
||||
const endTime = new Date();
|
||||
endTime.setHours(hour, minute + duration, 0, 0);
|
||||
if (endTime.getHours() <= endHour) {
|
||||
slots.push(timeStr);
|
||||
}
|
||||
// Fetch working hours for a given month
|
||||
async function fetchWorkingHoursForMonth(date: CalendarDate) {
|
||||
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||
if (workingHoursCache.has(monthKey)) {
|
||||
workingHours = workingHoursCache.get(monthKey)!;
|
||||
return;
|
||||
}
|
||||
|
||||
loadingWorkingHours = true;
|
||||
try {
|
||||
// Calculate start and end of month
|
||||
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
||||
const endOfMonth = new CalendarDate(
|
||||
date.year,
|
||||
date.month,
|
||||
date.calendar.getDaysInMonth(date)
|
||||
);
|
||||
|
||||
const startStr = startOfMonth.toString();
|
||||
const endStr = endOfMonth.toString();
|
||||
|
||||
const response = await fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
type WorkingHoursDay = {
|
||||
date: string;
|
||||
weekday: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isOpen: boolean;
|
||||
source: string;
|
||||
};
|
||||
|
||||
const data: Array<WorkingHoursDay> = await response.json();
|
||||
|
||||
const hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
||||
data.forEach((day) => {
|
||||
hoursMap[day.date] = {
|
||||
isOpen: day.isOpen,
|
||||
startTime: day.startTime,
|
||||
endTime: day.endTime
|
||||
};
|
||||
});
|
||||
|
||||
workingHoursCache.set(monthKey, hoursMap);
|
||||
workingHours = hoursMap;
|
||||
|
||||
// Set default selected date if not set
|
||||
if (!selectedDate) {
|
||||
setDefaultSelectedDate(hoursMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch working hours:', error);
|
||||
// Fallback to current date if API fails
|
||||
if (!selectedDate) {
|
||||
selectedDate = minDate;
|
||||
}
|
||||
} finally {
|
||||
loadingWorkingHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set default selected date to next available working day
|
||||
function setDefaultSelectedDate(
|
||||
hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||
) {
|
||||
const currentDate = new Date();
|
||||
let nextDate = new Date(currentDate);
|
||||
|
||||
// Check next 30 days for available working day
|
||||
for (let i = 0; i < 30; i++) {
|
||||
nextDate.setDate(currentDate.getDate() + i);
|
||||
const dateStr = nextDate.toISOString().split('T')[0];
|
||||
|
||||
if (hoursMap[dateStr]?.isOpen) {
|
||||
selectedDate = new CalendarDate(
|
||||
nextDate.getFullYear(),
|
||||
nextDate.getMonth() + 1,
|
||||
nextDate.getDate()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to today if no working day found
|
||||
if (!selectedDate) {
|
||||
selectedDate = minDate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the end time based on a 24-hour start time and a duration in minutes.
|
||||
* @param startTime - Time string in "HH:MM:SS" format (e.g., "09:00:00").
|
||||
* @param durationMinutes - The total duration of the service in minutes.
|
||||
* @returns Time string in "HH:MM" 24-hour format (e.g., "10:30").
|
||||
*/
|
||||
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
|
||||
// Create a fixed date object to manipulate the time
|
||||
const date = new Date();
|
||||
// Set time on the fixed date
|
||||
date.setHours(hours, minutes, 0, 0);
|
||||
|
||||
// Add duration in minutes
|
||||
date.setMinutes(date.getMinutes() + durationMinutes);
|
||||
|
||||
// Format back to HH:MM (24-hour format)
|
||||
const endHours = date.getHours().toString().padStart(2, '0');
|
||||
const endMinutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${endHours}:${endMinutes}`;
|
||||
}
|
||||
|
||||
/** Format time from HH:MM:SS (or HH:MM) to human-readable 12-hour format, with "Noon" for 12:00 PM */
|
||||
function formatTime(time: string): string {
|
||||
// Handle cases where seconds might be present (HH:MM:SS) or not (HH:MM)
|
||||
const parts = time.split(':').map(Number);
|
||||
const hours = parts[0];
|
||||
const minutes = parts.length > 1 ? parts[1] : 0;
|
||||
|
||||
// Special case for 12:00
|
||||
if (hours === 12 && minutes === 0) {
|
||||
return 'Noon';
|
||||
} else if (hours === 0 && minutes === 0) {
|
||||
return 'Midnight';
|
||||
}
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
// Initialize with current month
|
||||
fetchWorkingHoursForMonth(minDate);
|
||||
|
||||
// Handle calendar month change
|
||||
function handleMonthChange(newMonth: CalendarDate) {
|
||||
// Only fetch if within 6 months range
|
||||
if (newMonth.compare(maxCalendarDate) <= 0) {
|
||||
fetchWorkingHoursForMonth(newMonth);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if date is unavailable - updated to handle DateValue type
|
||||
function isDateUnavailable(date: DateValue): boolean {
|
||||
// Only CalendarDate has compare method
|
||||
if (!(date instanceof CalendarDate)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if date is outside allowed range
|
||||
if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we have working hours data
|
||||
if (!workingHours) return false;
|
||||
|
||||
const dateStr = date.toString();
|
||||
const dayHours = workingHours[dateStr];
|
||||
|
||||
// If no data for this date, assume unavailable
|
||||
if (!dayHours) return true;
|
||||
|
||||
return !dayHours.isOpen;
|
||||
}
|
||||
|
||||
function generateTimeSlots(duration: number, date: CalendarDate | undefined) {
|
||||
if (!date || !workingHours) return [];
|
||||
|
||||
const dateStr = date.toString();
|
||||
const dayHours = workingHours[dateStr];
|
||||
|
||||
// If no working hours data or closed, return empty
|
||||
if (!dayHours || !dayHours.isOpen) return [];
|
||||
|
||||
const slots = [];
|
||||
const [startHour, startMinute] = dayHours.startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = dayHours.endTime.split(':').map(Number);
|
||||
|
||||
// Convert to minutes for easier calculation
|
||||
const startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
// Generate 15-minute intervals
|
||||
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
||||
const slotEndMinutes = minutes + duration;
|
||||
|
||||
// Skip if slot would end after closing time
|
||||
if (slotEndMinutes > endTotalMinutes) continue;
|
||||
|
||||
const hour = Math.floor(minutes / 60);
|
||||
const minute = minutes % 60;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
|
||||
slots.push(timeStr);
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
// Generate time slots based on selected services (use longest duration)
|
||||
// Generate time slots based on selected services and date
|
||||
const timeSlots = $derived(
|
||||
selectedServices.length > 0 ? generateTimeSlots(getTotalDuration()) : []
|
||||
selectedServices.length > 0 && selectedDate
|
||||
? generateTimeSlots(getTotalDuration(), selectedDate)
|
||||
: []
|
||||
);
|
||||
|
||||
function getTotalDuration() {
|
||||
@@ -125,6 +338,8 @@
|
||||
} else {
|
||||
selectedServices = [...selectedServices, service];
|
||||
}
|
||||
// Reset time selection when services change
|
||||
selectedTime = null;
|
||||
}
|
||||
|
||||
function isServiceSelected(service: any) {
|
||||
@@ -157,7 +372,6 @@
|
||||
function nextStep() {
|
||||
if (currentStep < 4) {
|
||||
currentStep++;
|
||||
// Use setTimeout to ensure DOM is updated before scrolling
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, 50);
|
||||
@@ -167,7 +381,6 @@
|
||||
function prevStep() {
|
||||
if (currentStep > 1) {
|
||||
currentStep--;
|
||||
// Use setTimeout to ensure DOM is updated before scrolling
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, 50);
|
||||
@@ -175,7 +388,6 @@
|
||||
}
|
||||
|
||||
function handleBooking() {
|
||||
// This would connect to your backend
|
||||
alert('Booking submitted! (This is just a prototype - no backend connected yet)');
|
||||
}
|
||||
|
||||
@@ -238,7 +450,7 @@
|
||||
class="focus:ring-primary cursor-pointer rounded-lg p-4 text-left shadow-sm transition-colors hover:bg-fuchsia-50 {isServiceSelected(
|
||||
service
|
||||
)
|
||||
? 'bg-fuchsia-100'
|
||||
? 'bg-fuchsia-200'
|
||||
: 'border-ring'}"
|
||||
onclick={() => toggleService(service)}
|
||||
>
|
||||
@@ -314,46 +526,48 @@
|
||||
</Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<Card.Root class="gap-0 border-0 p-0">
|
||||
<Card.Content class="relative p-0 md:pr-48">
|
||||
<Card.Content class="relative p-0 md:pr-56">
|
||||
<div class="flex items-center justify-center p-6">
|
||||
<Calendar
|
||||
type="single"
|
||||
bind:value={selectedDate}
|
||||
isDateUnavailable={(date) => bookedDates.some((d) => d.compare(date) === 0)}
|
||||
bind:placeholder
|
||||
{isDateUnavailable}
|
||||
class="data-unavailable:line-through data-unavailable:opacity-100 bg-transparent p-0 [--cell-size:--spacing(10)] md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:hidden"
|
||||
weekdayFormat="short"
|
||||
minValue={minDate}
|
||||
maxValue={maxCalendarDate}
|
||||
weekStartsOn={1}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="no-scrollbar inset-y-0 right-0 flex max-h-48 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-l md:border-t-0"
|
||||
class="no-scrollbar inset-y-0 right-0 flex max-h-48 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-l md:border-t-0"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
{#if timeSlots.length > 0}
|
||||
{#if loadingWorkingHours && selectedDate}
|
||||
<div class="text-center text-sm text-gray-500">Loading available times...</div>
|
||||
{:else if timeSlots.length > 0}
|
||||
{@const duration = getTotalDuration()}
|
||||
<div class="grid gap-2">
|
||||
{#each timeSlots as time (time)}
|
||||
{@const timeStatus = isTimeInDuration(time, selectedTime, getTotalDuration())}
|
||||
{@const endTime = calculateEndTime(time, duration)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (selectedTime = time)}
|
||||
class={`w-full hover:bg-fuchsia-50 ${
|
||||
timeStatus === 'start'
|
||||
? ' text-primary bg-fuchsia-200'
|
||||
: timeStatus === 'duration'
|
||||
? ' text-primary bg-fuchsia-100'
|
||||
: ''
|
||||
time === selectedTime ? ' bg-fuchsia-200' : ''
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
{#if timeStatus === 'duration'}
|
||||
<span class="ml-1 text-xs opacity-70">(selected)</span>
|
||||
{/if}
|
||||
{formatTime(time)} <span class="text-gray-500">- {formatTime(endTime)}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
{:else if selectedServices.length === 0}
|
||||
<p class="text-center text-sm text-gray-500">Select services first</p>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-gray-500">No available slots</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if selectedServices.length === 0}
|
||||
<p class="text-center text-sm text-gray-500">Select services first</p>
|
||||
{:else if !selectedDate}
|
||||
<p class="text-center text-sm text-gray-500">Select a date first</p>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-gray-500">No available slots</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -370,7 +584,7 @@
|
||||
month: 'short'
|
||||
})}
|
||||
</span>
|
||||
at <span class="font-medium">{selectedTime}</span>
|
||||
<br />at <span class="font-medium">{formatTime(selectedTime)}</span>
|
||||
{:else}
|
||||
Select a date and time
|
||||
{/if}
|
||||
@@ -390,7 +604,7 @@
|
||||
month: 'short'
|
||||
})}
|
||||
</span>
|
||||
at <span class="font-medium">{selectedTime}</span>
|
||||
at <span class="font-medium">{formatTime(selectedTime)}</span>
|
||||
{:else}
|
||||
Select a date and time
|
||||
{/if}
|
||||
@@ -511,7 +725,7 @@
|
||||
<Button
|
||||
disabled={!canProceedStep3}
|
||||
onclick={nextStep}
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
Next: Payment
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user