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:
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user