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>
423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
/**
|
|
* Business Hours Settings
|
|
*
|
|
* Configure weekly operating hours that automatically block customer bookings
|
|
* outside those times while allowing staff manual override.
|
|
*/
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks';
|
|
import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui';
|
|
import { BlockPurpose, TimeBlock } from '../../types';
|
|
|
|
interface DayHours {
|
|
enabled: boolean;
|
|
open: string; // "09:00"
|
|
close: string; // "17:00"
|
|
}
|
|
|
|
interface BusinessHours {
|
|
monday: DayHours;
|
|
tuesday: DayHours;
|
|
wednesday: DayHours;
|
|
thursday: DayHours;
|
|
friday: DayHours;
|
|
saturday: DayHours;
|
|
sunday: DayHours;
|
|
}
|
|
|
|
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] as const;
|
|
const DAY_LABELS: Record<typeof DAYS[number], string> = {
|
|
monday: 'Monday',
|
|
tuesday: 'Tuesday',
|
|
wednesday: 'Wednesday',
|
|
thursday: 'Thursday',
|
|
friday: 'Friday',
|
|
saturday: 'Saturday',
|
|
sunday: 'Sunday',
|
|
};
|
|
|
|
const DAY_INDICES: Record<typeof DAYS[number], number> = {
|
|
monday: 0,
|
|
tuesday: 1,
|
|
wednesday: 2,
|
|
thursday: 3,
|
|
friday: 4,
|
|
saturday: 5,
|
|
sunday: 6,
|
|
};
|
|
|
|
const DEFAULT_HOURS: BusinessHours = {
|
|
monday: { enabled: true, open: '09:00', close: '17:00' },
|
|
tuesday: { enabled: true, open: '09:00', close: '17:00' },
|
|
wednesday: { enabled: true, open: '09:00', close: '17:00' },
|
|
thursday: { enabled: true, open: '09:00', close: '17:00' },
|
|
friday: { enabled: true, open: '09:00', close: '17:00' },
|
|
saturday: { enabled: false, open: '09:00', close: '17:00' },
|
|
sunday: { enabled: false, open: '09:00', close: '17:00' },
|
|
};
|
|
|
|
const BusinessHoursSettings: React.FC = () => {
|
|
const [hours, setHours] = useState<BusinessHours>(DEFAULT_HOURS);
|
|
const [error, setError] = useState<string>('');
|
|
const [success, setSuccess] = useState<string>('');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// Fetch existing business hours time blocks
|
|
const { data: timeBlocks, isLoading } = useTimeBlocks({
|
|
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
|
is_active: true,
|
|
});
|
|
|
|
const createTimeBlock = useCreateTimeBlock();
|
|
const updateTimeBlock = useUpdateTimeBlock();
|
|
const deleteTimeBlock = useDeleteTimeBlock();
|
|
|
|
// Parse existing time blocks into UI state
|
|
useEffect(() => {
|
|
if (!timeBlocks || timeBlocks.length === 0) return;
|
|
|
|
const parsed: BusinessHours = { ...DEFAULT_HOURS };
|
|
|
|
// Group blocks by day
|
|
timeBlocks.forEach((block) => {
|
|
if (block.recurrence_type === 'WEEKLY' && block.recurrence_pattern?.days_of_week) {
|
|
const daysOfWeek = block.recurrence_pattern.days_of_week;
|
|
|
|
daysOfWeek.forEach((dayIndex) => {
|
|
const dayName = Object.keys(DAY_INDICES).find(
|
|
(key) => DAY_INDICES[key as typeof DAYS[number]] === dayIndex
|
|
) as typeof DAYS[number] | undefined;
|
|
|
|
if (dayName) {
|
|
// Check if this is a "before hours" or "after hours" block
|
|
if (block.start_time === '00:00:00') {
|
|
// Before hours block: 00:00 to open time
|
|
parsed[dayName].enabled = true;
|
|
parsed[dayName].open = block.end_time?.substring(0, 5) || '09:00';
|
|
} else if (block.end_time === '23:59:59' || block.end_time === '00:00:00') {
|
|
// After hours block: close time to 24:00
|
|
parsed[dayName].enabled = true;
|
|
parsed[dayName].close = block.start_time?.substring(0, 5) || '17:00';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
setHours(parsed);
|
|
}, [timeBlocks]);
|
|
|
|
const handleDayToggle = (day: typeof DAYS[number]) => {
|
|
setHours({
|
|
...hours,
|
|
[day]: {
|
|
...hours[day],
|
|
enabled: !hours[day].enabled,
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleTimeChange = (day: typeof DAYS[number], field: 'open' | 'close', value: string) => {
|
|
setHours({
|
|
...hours,
|
|
[day]: {
|
|
...hours[day],
|
|
[field]: value,
|
|
},
|
|
});
|
|
};
|
|
|
|
const validateHours = (): boolean => {
|
|
setError('');
|
|
|
|
// Check that enabled days have valid times
|
|
for (const day of DAYS) {
|
|
if (hours[day].enabled) {
|
|
const open = hours[day].open;
|
|
const close = hours[day].close;
|
|
|
|
if (!open || !close) {
|
|
setError(`Please set both open and close times for ${DAY_LABELS[day]}`);
|
|
return false;
|
|
}
|
|
|
|
if (open >= close) {
|
|
setError(`${DAY_LABELS[day]}: Close time must be after open time`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!validateHours()) return;
|
|
|
|
setIsSaving(true);
|
|
setError('');
|
|
setSuccess('');
|
|
|
|
try {
|
|
console.log('Starting save, existing blocks:', timeBlocks);
|
|
|
|
// Delete all existing business hours blocks
|
|
if (timeBlocks && timeBlocks.length > 0) {
|
|
console.log('Deleting', timeBlocks.length, 'existing blocks');
|
|
for (const block of timeBlocks) {
|
|
try {
|
|
await deleteTimeBlock.mutateAsync(block.id);
|
|
console.log('Deleted block:', block.id);
|
|
} catch (delErr: any) {
|
|
console.error('Error deleting block:', block.id, delErr);
|
|
throw new Error(`Failed to delete existing block: ${delErr.response?.data?.message || delErr.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Group days by hours for efficient block creation
|
|
const hourGroups: Map<string, number[]> = new Map();
|
|
|
|
DAYS.forEach((day) => {
|
|
if (hours[day].enabled) {
|
|
const key = `${hours[day].open}-${hours[day].close}`;
|
|
const dayIndex = DAY_INDICES[day];
|
|
|
|
if (!hourGroups.has(key)) {
|
|
hourGroups.set(key, []);
|
|
}
|
|
hourGroups.get(key)!.push(dayIndex);
|
|
}
|
|
});
|
|
|
|
console.log('Hour groups:', Array.from(hourGroups.entries()));
|
|
|
|
// Create new time blocks for each group
|
|
for (const [hoursKey, daysOfWeek] of hourGroups.entries()) {
|
|
const [open, close] = hoursKey.split('-');
|
|
|
|
// Before hours block: 00:00 to open time
|
|
try {
|
|
const beforeBlock = await createTimeBlock.mutateAsync({
|
|
title: 'Before Business Hours',
|
|
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
|
block_type: 'SOFT',
|
|
resource: null,
|
|
recurrence_type: 'WEEKLY',
|
|
recurrence_pattern: { days_of_week: daysOfWeek },
|
|
all_day: false,
|
|
start_time: '00:00:00',
|
|
end_time: `${open}:00`,
|
|
is_active: true,
|
|
});
|
|
console.log('Created before-hours block:', beforeBlock);
|
|
} catch (createErr: any) {
|
|
console.error('Error creating before-hours block:', createErr);
|
|
throw new Error(`Failed to create before-hours block: ${createErr.response?.data?.message || createErr.message}`);
|
|
}
|
|
|
|
// After hours block: close time to 23:59:59
|
|
try {
|
|
const afterBlock = await createTimeBlock.mutateAsync({
|
|
title: 'After Business Hours',
|
|
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
|
block_type: 'SOFT',
|
|
resource: null,
|
|
recurrence_type: 'WEEKLY',
|
|
recurrence_pattern: { days_of_week: daysOfWeek },
|
|
all_day: false,
|
|
start_time: `${close}:00`,
|
|
end_time: '23:59:59',
|
|
is_active: true,
|
|
});
|
|
console.log('Created after-hours block:', afterBlock);
|
|
} catch (createErr: any) {
|
|
console.error('Error creating after-hours block:', createErr);
|
|
throw new Error(`Failed to create after-hours block: ${createErr.response?.data?.message || createErr.message}`);
|
|
}
|
|
}
|
|
|
|
console.log('Save completed successfully');
|
|
setSuccess('Business hours saved successfully! Customer bookings will be blocked outside these hours.');
|
|
} catch (err: any) {
|
|
console.error('Save error:', err);
|
|
setError(err.message || err.response?.data?.message || 'Failed to save business hours. Please try again.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<LoadingSpinner size="lg" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-6">
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Business Hours</h1>
|
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
Set your regular operating hours. Customer bookings will be blocked outside these times,
|
|
but staff can still manually schedule appointments if needed.
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert variant="error" className="mb-4">
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{success && (
|
|
<Alert variant="success" className="mb-4">
|
|
{success}
|
|
</Alert>
|
|
)}
|
|
|
|
<Card>
|
|
<div className="space-y-4">
|
|
{DAYS.map((day) => (
|
|
<div
|
|
key={day}
|
|
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
|
>
|
|
<div className="flex items-center gap-3 w-40">
|
|
<input
|
|
type="checkbox"
|
|
id={`${day}-enabled`}
|
|
checked={hours[day].enabled}
|
|
onChange={() => handleDayToggle(day)}
|
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
|
/>
|
|
<label
|
|
htmlFor={`${day}-enabled`}
|
|
className="text-sm font-medium text-gray-900 dark:text-white cursor-pointer"
|
|
>
|
|
{DAY_LABELS[day]}
|
|
</label>
|
|
</div>
|
|
|
|
{hours[day].enabled ? (
|
|
<div className="flex items-center gap-4 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-gray-600 dark:text-gray-400">Open:</label>
|
|
<input
|
|
type="time"
|
|
value={hours[day].open}
|
|
onChange={(e) => handleTimeChange(day, 'open', e.target.value)}
|
|
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-gray-600 dark:text-gray-400">Close:</label>
|
|
<input
|
|
type="time"
|
|
value={hours[day].close}
|
|
onChange={(e) => handleTimeChange(day, 'close', e.target.value)}
|
|
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
({calculateHours(hours[day].open, hours[day].close)} hours)
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 flex-1">
|
|
Closed
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
<strong>Note:</strong> These hours apply to customer bookings only. Staff can override.
|
|
</div>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
variant="primary"
|
|
>
|
|
{isSaving ? 'Saving...' : 'Save Business Hours'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Preview */}
|
|
<Card className="mt-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Preview
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{DAYS.map((day) => (
|
|
<div key={day} className="flex items-center justify-between text-sm">
|
|
<span className="font-medium text-gray-900 dark:text-white">
|
|
{DAY_LABELS[day]}:
|
|
</span>
|
|
<span className="text-gray-600 dark:text-gray-400">
|
|
{hours[day].enabled
|
|
? `${formatTime(hours[day].open)} - ${formatTime(hours[day].close)}`
|
|
: 'Closed'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Helper functions
|
|
const calculateHours = (open: string, close: string): string => {
|
|
try {
|
|
if (!open || !close || !open.includes(':') || !close.includes(':')) {
|
|
return '0';
|
|
}
|
|
|
|
const [openHour, openMin] = open.split(':').map(Number);
|
|
const [closeHour, closeMin] = close.split(':').map(Number);
|
|
|
|
if (isNaN(openHour) || isNaN(openMin) || isNaN(closeHour) || isNaN(closeMin)) {
|
|
return '0';
|
|
}
|
|
|
|
const totalMinutes = (closeHour * 60 + closeMin) - (openHour * 60 + openMin);
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const minutes = totalMinutes % 60;
|
|
|
|
if (minutes === 0) return `${hours}`;
|
|
return `${hours}.${minutes < 10 ? '0' : ''}${minutes}`;
|
|
} catch (e) {
|
|
return '0';
|
|
}
|
|
};
|
|
|
|
const formatTime = (time: string): string => {
|
|
try {
|
|
if (!time || !time.includes(':')) {
|
|
return time;
|
|
}
|
|
|
|
const [hour, min] = time.split(':').map(Number);
|
|
|
|
if (isNaN(hour) || isNaN(min)) {
|
|
return time;
|
|
}
|
|
|
|
const period = hour >= 12 ? 'PM' : 'AM';
|
|
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
|
return `${displayHour}:${min.toString().padStart(2, '0')} ${period}`;
|
|
} catch (e) {
|
|
return time;
|
|
}
|
|
};
|
|
|
|
export default BusinessHoursSettings;
|