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

@@ -1,8 +1,27 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import api from '../api/client';
export interface PublicService {
id: number;
name: string;
description: string;
duration: number;
price_cents: number;
deposit_amount_cents: number | null;
photos: string[] | null;
}
export interface PublicBusinessInfo {
name: string;
logo_url: string | null;
primary_color: string;
secondary_color: string | null;
service_selection_heading: string;
service_selection_subheading: string;
}
export const usePublicServices = () => {
return useQuery({
return useQuery<PublicService[]>({
queryKey: ['publicServices'],
queryFn: async () => {
const response = await api.get('/public/services/');
@@ -12,8 +31,51 @@ export const usePublicServices = () => {
});
};
export const usePublicAvailability = (serviceId: string, date: string) => {
return useQuery({
export const usePublicBusinessInfo = () => {
return useQuery<PublicBusinessInfo>({
queryKey: ['publicBusinessInfo'],
queryFn: async () => {
const response = await api.get('/public/business/');
return response.data;
},
retry: false,
});
};
export interface AvailabilitySlot {
time: string; // ISO datetime string
display: string; // Human-readable time like "9:00 AM"
available: boolean;
}
export interface AvailabilityResponse {
date: string;
service_id: number;
is_open: boolean;
business_hours?: {
start: string;
end: string;
};
slots: AvailabilitySlot[];
business_timezone?: string;
timezone_display_mode?: 'business' | 'viewer';
}
export interface BusinessHoursDay {
date: string;
is_open: boolean;
hours: {
start: string;
end: string;
} | null;
}
export interface BusinessHoursResponse {
dates: BusinessHoursDay[];
}
export const usePublicAvailability = (serviceId: number | undefined, date: string | undefined) => {
return useQuery<AvailabilityResponse>({
queryKey: ['publicAvailability', serviceId, date],
queryFn: async () => {
const response = await api.get(`/public/availability/?service_id=${serviceId}&date=${date}`);
@@ -23,6 +85,17 @@ export const usePublicAvailability = (serviceId: string, date: string) => {
});
};
export const usePublicBusinessHours = (startDate: string | undefined, endDate: string | undefined) => {
return useQuery<BusinessHoursResponse>({
queryKey: ['publicBusinessHours', startDate, endDate],
queryFn: async () => {
const response = await api.get(`/public/business-hours/?start_date=${startDate}&end_date=${endDate}`);
return response.data;
},
enabled: !!startDate && !!endDate,
});
};
export const useCreateBooking = () => {
return useMutation({
mutationFn: async (data: any) => {

View File

@@ -48,6 +48,9 @@ export const useCurrentBusiness = () => {
initialSetupComplete: data.initial_setup_complete,
websitePages: data.website_pages || {},
customerDashboardContent: data.customer_dashboard_content || [],
// Booking page customization
serviceSelectionHeading: data.service_selection_heading || 'Choose your experience',
serviceSelectionSubheading: data.service_selection_subheading || 'Select a service to begin your booking.',
paymentsEnabled: data.payments_enabled ?? false,
// Platform-controlled permissions
canManageOAuthCredentials: data.can_manage_oauth_credentials || false,
@@ -118,6 +121,12 @@ export const useUpdateBusiness = () => {
if (updates.customerDashboardContent !== undefined) {
backendData.customer_dashboard_content = updates.customerDashboardContent;
}
if (updates.serviceSelectionHeading !== undefined) {
backendData.service_selection_heading = updates.serviceSelectionHeading;
}
if (updates.serviceSelectionSubheading !== undefined) {
backendData.service_selection_subheading = updates.serviceSelectionSubheading;
}
const { data } = await apiClient.patch('/business/current/update/', backendData);
return data;

View File

@@ -21,16 +21,25 @@ export const useServices = () => {
name: s.name,
durationMinutes: s.duration || s.duration_minutes,
price: parseFloat(s.price),
price_cents: s.price_cents ?? Math.round(parseFloat(s.price) * 100),
description: s.description || '',
displayOrder: s.display_order ?? 0,
photos: s.photos || [],
is_active: s.is_active ?? true,
created_at: s.created_at,
is_archived_by_quota: s.is_archived_by_quota ?? false,
// Pricing fields
variable_pricing: s.variable_pricing ?? false,
deposit_amount: s.deposit_amount ? parseFloat(s.deposit_amount) : null,
deposit_amount_cents: s.deposit_amount_cents ?? (s.deposit_amount ? Math.round(parseFloat(s.deposit_amount) * 100) : null),
deposit_percent: s.deposit_percent ? parseFloat(s.deposit_percent) : null,
requires_deposit: s.requires_deposit ?? false,
requires_saved_payment_method: s.requires_saved_payment_method ?? false,
deposit_display: s.deposit_display || null,
// Resource assignment
all_resources: s.all_resources ?? true,
resource_ids: (s.resource_ids || []).map((id: number) => String(id)),
resource_names: s.resource_names || [],
}));
},
retry: false, // Don't retry on 404 - endpoint may not exist yet
@@ -65,12 +74,26 @@ export const useService = (id: string) => {
interface ServiceInput {
name: string;
durationMinutes: number;
price: number;
price?: number; // Price in dollars
price_cents?: number; // Price in cents (preferred)
description?: string;
photos?: string[];
variable_pricing?: boolean;
deposit_amount?: number | null;
deposit_amount?: number | null; // Deposit in dollars
deposit_amount_cents?: number | null; // Deposit in cents (preferred)
deposit_percent?: number | null;
// Resource assignment (not yet implemented in backend)
all_resources?: boolean;
resource_ids?: string[];
// Buffer times (not yet implemented in backend)
prep_time?: number;
takedown_time?: number;
// Notification settings (not yet implemented in backend)
reminder_enabled?: boolean;
reminder_hours_before?: number;
reminder_email?: boolean;
reminder_sms?: boolean;
thank_you_email_enabled?: boolean;
}
/**
@@ -81,10 +104,15 @@ export const useCreateService = () => {
return useMutation({
mutationFn: async (serviceData: ServiceInput) => {
// Convert price: prefer cents, fall back to dollars
const priceInDollars = serviceData.price_cents !== undefined
? (serviceData.price_cents / 100).toString()
: (serviceData.price ?? 0).toString();
const backendData: Record<string, any> = {
name: serviceData.name,
duration: serviceData.durationMinutes,
price: serviceData.price.toString(),
price: priceInDollars,
description: serviceData.description || '',
photos: serviceData.photos || [],
};
@@ -93,13 +121,29 @@ export const useCreateService = () => {
if (serviceData.variable_pricing !== undefined) {
backendData.variable_pricing = serviceData.variable_pricing;
}
if (serviceData.deposit_amount !== undefined) {
// Convert deposit: prefer cents, fall back to dollars
if (serviceData.deposit_amount_cents !== undefined) {
backendData.deposit_amount = serviceData.deposit_amount_cents !== null
? serviceData.deposit_amount_cents / 100
: null;
} else if (serviceData.deposit_amount !== undefined) {
backendData.deposit_amount = serviceData.deposit_amount;
}
if (serviceData.deposit_percent !== undefined) {
backendData.deposit_percent = serviceData.deposit_percent;
}
// Resource assignment
if (serviceData.all_resources !== undefined) {
backendData.all_resources = serviceData.all_resources;
}
if (serviceData.resource_ids !== undefined) {
// Convert string IDs to numbers for the backend
backendData.resource_ids = serviceData.resource_ids.map(id => parseInt(id, 10));
}
const { data } = await apiClient.post('/services/', backendData);
return data;
},
@@ -120,14 +164,38 @@ export const useUpdateService = () => {
const backendData: Record<string, any> = {};
if (updates.name) backendData.name = updates.name;
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
if (updates.price !== undefined) backendData.price = updates.price.toString();
// Convert price: prefer cents, fall back to dollars
if (updates.price_cents !== undefined) {
backendData.price = (updates.price_cents / 100).toString();
} else if (updates.price !== undefined) {
backendData.price = updates.price.toString();
}
if (updates.description !== undefined) backendData.description = updates.description;
if (updates.photos !== undefined) backendData.photos = updates.photos;
// Pricing fields
if (updates.variable_pricing !== undefined) backendData.variable_pricing = updates.variable_pricing;
if (updates.deposit_amount !== undefined) backendData.deposit_amount = updates.deposit_amount;
// Convert deposit: prefer cents, fall back to dollars
if (updates.deposit_amount_cents !== undefined) {
backendData.deposit_amount = updates.deposit_amount_cents !== null
? updates.deposit_amount_cents / 100
: null;
} else if (updates.deposit_amount !== undefined) {
backendData.deposit_amount = updates.deposit_amount;
}
if (updates.deposit_percent !== undefined) backendData.deposit_percent = updates.deposit_percent;
// Resource assignment
if (updates.all_resources !== undefined) backendData.all_resources = updates.all_resources;
if (updates.resource_ids !== undefined) {
// Convert string IDs to numbers for the backend
backendData.resource_ids = updates.resource_ids.map(id => parseInt(id, 10));
}
const { data } = await apiClient.patch(`/services/${id}/`, backendData);
return data;
},

View File

@@ -46,6 +46,31 @@ export const useUpdatePage = () => {
});
};
export const useCreatePage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { title: string; slug?: string; is_home?: boolean }) => {
const response = await api.post('/sites/me/pages/', data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pages'] });
},
});
};
export const useDeletePage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/sites/me/pages/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pages'] });
},
});
};
export const usePublicPage = () => {
return useQuery({
queryKey: ['publicPage'],

View File

@@ -128,7 +128,9 @@ export const useBlockedDates = (params: BlockedDatesParams) => {
queryParams.append('include_business', String(params.include_business));
}
const { data } = await apiClient.get(`/time-blocks/blocked_dates/?${queryParams}`);
const url = `/time-blocks/blocked_dates/?${queryParams}`;
const { data } = await apiClient.get(url);
return data.blocked_dates.map((block: any) => ({
...block,
resource_id: block.resource_id ? String(block.resource_id) : null,