Add booking flow, business hours, and dark mode support
Features: - Complete multi-step booking flow with service selection, date/time picker, auth (login/signup with email verification), payment, and confirmation - Business hours settings page for defining when business is open - TimeBlock purpose field (BUSINESS_HOURS, CLOSURE, UNAVAILABLE) - Service resource assignment with prep/takedown time buffers - Availability checking respects business hours and service buffers - Customer registration via email verification code UI/UX: - Full dark mode support for all booking components - Separate first/last name fields in signup form - Back buttons on each wizard step - Removed auto-redirect from confirmation page API: - Public endpoints for services, availability, business hours - Customer verification and registration endpoints - Tenant lookup from X-Business-Subdomain header 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
276
frontend/src/components/booking/DateTimeSelection.tsx
Normal file
276
frontend/src/components/booking/DateTimeSelection.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2, XCircle } from 'lucide-react';
|
||||
import { usePublicAvailability, usePublicBusinessHours } from '../../hooks/useBooking';
|
||||
import { formatTimeForDisplay, getTimezoneAbbreviation, getUserTimezone } from '../../utils/dateUtils';
|
||||
|
||||
interface DateTimeSelectionProps {
|
||||
serviceId?: number;
|
||||
selectedDate: Date | null;
|
||||
selectedTimeSlot: string | null;
|
||||
onDateChange: (date: Date) => void;
|
||||
onTimeChange: (time: string) => void;
|
||||
}
|
||||
|
||||
export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
|
||||
serviceId,
|
||||
selectedDate,
|
||||
selectedTimeSlot,
|
||||
onDateChange,
|
||||
onTimeChange
|
||||
}) => {
|
||||
const today = new Date();
|
||||
const [currentMonth, setCurrentMonth] = React.useState(today.getMonth());
|
||||
const [currentYear, setCurrentYear] = React.useState(today.getFullYear());
|
||||
|
||||
// Calculate date range for business hours query (current month view)
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
const start = new Date(currentYear, currentMonth, 1);
|
||||
const end = new Date(currentYear, currentMonth + 1, 0);
|
||||
return {
|
||||
startDate: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-01`,
|
||||
endDate: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
|
||||
};
|
||||
}, [currentMonth, currentYear]);
|
||||
|
||||
// Fetch business hours for the month
|
||||
const { data: businessHours, isLoading: businessHoursLoading } = usePublicBusinessHours(startDate, endDate);
|
||||
|
||||
// Create a map of dates to their open status
|
||||
const openDaysMap = useMemo(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
if (businessHours?.dates) {
|
||||
businessHours.dates.forEach(day => {
|
||||
map.set(day.date, day.is_open);
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [businessHours]);
|
||||
|
||||
// Format selected date for API query (YYYY-MM-DD)
|
||||
const dateString = selectedDate
|
||||
? `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
|
||||
: undefined;
|
||||
|
||||
// Fetch availability when both serviceId and date are set
|
||||
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(serviceId, dateString);
|
||||
|
||||
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
if (currentMonth === 0) {
|
||||
setCurrentMonth(11);
|
||||
setCurrentYear(currentYear - 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
if (currentMonth === 11) {
|
||||
setCurrentMonth(0);
|
||||
setCurrentYear(currentYear + 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
const monthName = new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long' });
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
return selectedDate?.getDate() === day &&
|
||||
selectedDate?.getMonth() === currentMonth &&
|
||||
selectedDate?.getFullYear() === currentYear;
|
||||
};
|
||||
|
||||
const isPast = (day: number) => {
|
||||
const d = new Date(currentYear, currentMonth, day);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return d < now;
|
||||
};
|
||||
|
||||
const isClosed = (day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
// If we have business hours data, use it. Otherwise default to open (except past dates)
|
||||
if (openDaysMap.size > 0) {
|
||||
return openDaysMap.get(dateStr) === false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isDisabled = (day: number) => {
|
||||
return isPast(day) || isClosed(day);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Calendar Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<CalendarIcon className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
|
||||
Select Date
|
||||
</h3>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="font-medium text-gray-900 dark:text-white w-32 text-center">
|
||||
{monthName} {currentYear}
|
||||
</span>
|
||||
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-2 mb-2 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
<div>Sun</div><div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div><div>Fri</div><div>Sat</div>
|
||||
</div>
|
||||
|
||||
{businessHoursLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
|
||||
<div key={`empty-${i}`} />
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const past = isPast(day);
|
||||
const closed = isClosed(day);
|
||||
const disabled = isDisabled(day);
|
||||
const selected = isSelected(day);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentYear, currentMonth, day);
|
||||
onDateChange(newDate);
|
||||
}}
|
||||
className={`
|
||||
h-10 w-10 rounded-full flex items-center justify-center text-sm font-medium transition-all relative
|
||||
${selected
|
||||
? 'bg-indigo-600 dark:bg-indigo-500 text-white shadow-md'
|
||||
: closed
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: past
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400'
|
||||
}
|
||||
`}
|
||||
title={closed ? 'Business closed' : past ? 'Past date' : undefined}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-100 dark:bg-gray-700"></div>
|
||||
<span>Closed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-indigo-600 dark:bg-indigo-500"></div>
|
||||
<span>Selected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Slots Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Available Time Slots</h3>
|
||||
{!selectedDate ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
Please select a date first
|
||||
</div>
|
||||
) : availabilityLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-red-500 dark:text-red-400">
|
||||
<XCircle className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium">Failed to load availability</p>
|
||||
<p className="text-sm mt-1 text-gray-500 dark:text-gray-400">
|
||||
{error instanceof Error ? error.message : 'Please try again'}
|
||||
</p>
|
||||
</div>
|
||||
) : availability?.is_open === false ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<XCircle className="w-12 h-12 mb-3 text-gray-300 dark:text-gray-600" />
|
||||
<p className="font-medium">Business Closed</p>
|
||||
<p className="text-sm mt-1">Please select another date</p>
|
||||
</div>
|
||||
) : availability?.slots && availability.slots.length > 0 ? (
|
||||
<>
|
||||
{(() => {
|
||||
// Determine which timezone to display based on business settings
|
||||
const displayTimezone = availability.timezone_display_mode === 'viewer'
|
||||
? getUserTimezone()
|
||||
: availability.business_timezone || getUserTimezone();
|
||||
const tzAbbrev = getTimezoneAbbreviation(displayTimezone);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
{availability.business_hours && (
|
||||
<>Business hours: {availability.business_hours.start} - {availability.business_hours.end} • </>
|
||||
)}
|
||||
Times shown in {tzAbbrev}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{availability.slots.map((slot) => {
|
||||
// Format time in the appropriate timezone
|
||||
const displayTime = formatTimeForDisplay(
|
||||
slot.time,
|
||||
availability.timezone_display_mode === 'viewer' ? null : availability.business_timezone
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slot.time}
|
||||
disabled={!slot.available}
|
||||
onClick={() => onTimeChange(displayTime)}
|
||||
className={`
|
||||
py-3 px-4 rounded-lg text-sm font-medium border transition-all duration-200
|
||||
${!slot.available
|
||||
? 'bg-gray-50 dark:bg-gray-700 text-gray-400 dark:text-gray-500 border-gray-100 dark:border-gray-600 cursor-not-allowed'
|
||||
: selectedTimeSlot === displayTime
|
||||
? 'bg-indigo-600 dark:bg-indigo-500 text-white border-indigo-600 dark:border-indigo-500 shadow-sm'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{displayTime}
|
||||
{!slot.available && <span className="block text-[10px] font-normal">Booked</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
) : !serviceId ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
Please select a service first
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
No available time slots for this date
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user