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:
422
frontend/src/pages/settings/BusinessHoursSettings.tsx
Normal file
422
frontend/src/pages/settings/BusinessHoursSettings.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user