Service Addons: - Add ServiceAddon model with optional resource assignment - Create AddonSelection component for booking flow - Add ServiceAddonManager for service configuration - Include addon API endpoints and serializers Manual Scheduling: - Add requires_manual_scheduling and capture_preferred_time to Service model - Add preferred_datetime and preferred_time_notes to Event model - Create ManualSchedulingRequest component for booking callback flow - Auto-open pending sidebar when requests exist or arrive via websocket - Show preferred times on pending items with detail modal popup - Add interactive UnscheduledBookingDemo component for help docs Scheduler Improvements: - Consolidate Create/EditAppointmentModal into single AppointmentModal - Update pending sidebar to show preferred schedule info - Add modal for pending request details with Schedule Now action Documentation: - Add Manual Scheduling section to HelpScheduler with interactive demo - Add Manual Scheduling section to HelpServices with interactive demo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
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;
|
|
selectedAddonIds?: number[];
|
|
onDateChange: (date: Date) => void;
|
|
onTimeChange: (time: string) => void;
|
|
}
|
|
|
|
export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
|
|
serviceId,
|
|
selectedDate,
|
|
selectedTimeSlot,
|
|
selectedAddonIds = [],
|
|
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
|
|
// Pass addon IDs to check availability for addon resources too
|
|
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(
|
|
serviceId,
|
|
dateString,
|
|
selectedAddonIds.length > 0 ? selectedAddonIds : undefined
|
|
);
|
|
|
|
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>
|
|
);
|
|
};
|