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:
poduck
2025-12-11 20:20:18 -05:00
parent 76c0d71aa0
commit 4a66246708
61 changed files with 6083 additions and 855 deletions

View File

@@ -0,0 +1,422 @@
/**
* 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;