diff --git a/frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx b/frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
index 0227347..1328338 100644
--- a/frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
+++ b/frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
@@ -9,7 +9,7 @@
*/
import React, { useMemo, useState } from 'react';
-import { BlockedDate, BlockType } from '../../types';
+import { BlockedDate, BlockType, BlockPurpose } from '../../types';
interface TimeBlockCalendarOverlayProps {
blockedDates: BlockedDate[];
@@ -126,61 +126,46 @@ const TimeBlockCalendarOverlay: React.FC = ({
return overlays;
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
- const getBlockStyle = (blockType: BlockType, isBusinessLevel: boolean): React.CSSProperties => {
+ const getBlockStyle = (blockType: BlockType, purpose: BlockPurpose, isBusinessLevel: boolean): React.CSSProperties => {
const baseStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
height: '100%',
pointerEvents: 'auto',
cursor: 'default',
+ zIndex: 5, // Ensure overlays are visible above grid lines
};
+ // Business-level blocks (including business hours): Simple gray background
+ // No fancy styling - just indicates "not available for booking"
if (isBusinessLevel) {
- // Business blocks: Red (hard) / Amber (soft)
- if (blockType === 'HARD') {
- return {
- ...baseStyle,
- background: `repeating-linear-gradient(
- -45deg,
- rgba(239, 68, 68, 0.3),
- rgba(239, 68, 68, 0.3) 5px,
- rgba(239, 68, 68, 0.5) 5px,
- rgba(239, 68, 68, 0.5) 10px
- )`,
- borderTop: '2px solid rgba(239, 68, 68, 0.7)',
- borderBottom: '2px solid rgba(239, 68, 68, 0.7)',
- };
- } else {
- return {
- ...baseStyle,
- background: 'rgba(251, 191, 36, 0.2)',
- borderTop: '2px dashed rgba(251, 191, 36, 0.8)',
- borderBottom: '2px dashed rgba(251, 191, 36, 0.8)',
- };
- }
+ return {
+ ...baseStyle,
+ background: 'rgba(107, 114, 128, 0.25)', // Gray-500 at 25% opacity (more visible)
+ };
+ }
+
+ // Resource-level blocks: Purple (hard) / Cyan (soft)
+ if (blockType === 'HARD') {
+ return {
+ ...baseStyle,
+ background: `repeating-linear-gradient(
+ -45deg,
+ rgba(147, 51, 234, 0.25),
+ rgba(147, 51, 234, 0.25) 5px,
+ rgba(147, 51, 234, 0.4) 5px,
+ rgba(147, 51, 234, 0.4) 10px
+ )`,
+ borderTop: '2px solid rgba(147, 51, 234, 0.7)',
+ borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
+ };
} else {
- // Resource blocks: Purple (hard) / Cyan (soft)
- if (blockType === 'HARD') {
- return {
- ...baseStyle,
- background: `repeating-linear-gradient(
- -45deg,
- rgba(147, 51, 234, 0.25),
- rgba(147, 51, 234, 0.25) 5px,
- rgba(147, 51, 234, 0.4) 5px,
- rgba(147, 51, 234, 0.4) 10px
- )`,
- borderTop: '2px solid rgba(147, 51, 234, 0.7)',
- borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
- };
- } else {
- return {
- ...baseStyle,
- background: 'rgba(6, 182, 212, 0.15)',
- borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
- borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
- };
- }
+ return {
+ ...baseStyle,
+ background: 'rgba(6, 182, 212, 0.15)',
+ borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
+ borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
+ };
}
};
@@ -208,7 +193,7 @@ const TimeBlockCalendarOverlay: React.FC = ({
<>
{blockOverlays.map((overlay, index) => {
const isBusinessLevel = overlay.block.resource_id === null;
- const style = getBlockStyle(overlay.block.block_type, isBusinessLevel);
+ const style = getBlockStyle(overlay.block.block_type, overlay.block.purpose, isBusinessLevel);
return (
= ({
onMouseLeave={handleMouseLeave}
onClick={() => onDayClick?.(days[overlay.dayIndex])}
>
- {/* Block level indicator */}
-
- {isBusinessLevel ? 'B' : 'R'}
-
+ {/* Only show badge for resource-level blocks */}
+ {!isBusinessLevel && (
+
+ R
+
+ )}
);
})}
diff --git a/frontend/src/components/ui/CurrencyInput.tsx b/frontend/src/components/ui/CurrencyInput.tsx
index d0cc0e9..bcf4697 100644
--- a/frontend/src/components/ui/CurrencyInput.tsx
+++ b/frontend/src/components/ui/CurrencyInput.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useRef } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
interface CurrencyInputProps {
value: number; // Value in cents (integer)
@@ -12,15 +12,15 @@ interface CurrencyInputProps {
}
/**
- * ATM-style currency input where digits are entered as cents.
- * As more digits are entered, they shift from cents to dollars.
- * Only accepts integer values (digits 0-9).
+ * Currency input where digits represent cents.
+ * Only accepts integer input (0-9), no decimal points.
+ * Allows normal text selection and editing.
*
- * Example: typing "1234" displays "$12.34"
- * - Type "1" → $0.01
- * - Type "2" → $0.12
- * - Type "3" → $1.23
- * - Type "4" → $12.34
+ * Examples:
+ * - Type "5" → $0.05
+ * - Type "50" → $0.50
+ * - Type "500" → $5.00
+ * - Type "1234" → $12.34
*/
const CurrencyInput: React.FC = ({
value,
@@ -33,128 +33,110 @@ const CurrencyInput: React.FC = ({
max,
}) => {
const inputRef = useRef(null);
- const [isFocused, setIsFocused] = useState(false);
-
- // Ensure value is always an integer
- const safeValue = Math.floor(Math.abs(value)) || 0;
+ const [displayValue, setDisplayValue] = useState('');
// Format cents as dollars string (e.g., 1234 → "$12.34")
const formatCentsAsDollars = (cents: number): string => {
- if (cents === 0 && !isFocused) return '';
+ if (cents === 0) return '';
const dollars = cents / 100;
return `$${dollars.toFixed(2)}`;
};
- const displayValue = safeValue > 0 || isFocused ? formatCentsAsDollars(safeValue) : '';
-
- // Process a new digit being added
- const addDigit = (digit: number) => {
- let newValue = safeValue * 10 + digit;
-
- // Enforce max if specified
- if (max !== undefined && newValue > max) {
- newValue = max;
- }
-
- onChange(newValue);
+ // Extract just the digits from a string
+ const extractDigits = (str: string): string => {
+ return str.replace(/\D/g, '');
};
- // Remove the last digit
- const removeDigit = () => {
- const newValue = Math.floor(safeValue / 10);
- onChange(newValue);
+ // Sync display value when external value changes
+ useEffect(() => {
+ setDisplayValue(formatCentsAsDollars(value));
+ }, [value]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const input = e.target.value;
+
+ // Extract only digits
+ const digits = extractDigits(input);
+
+ // Convert to cents (the digits ARE the cents value)
+ let cents = digits ? parseInt(digits, 10) : 0;
+
+ // Enforce max if specified
+ if (max !== undefined && cents > max) {
+ cents = max;
+ }
+
+ onChange(cents);
+
+ // Update display immediately with formatted value
+ setDisplayValue(formatCentsAsDollars(cents));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
- // Allow navigation keys without preventing default
- if (
- e.key === 'Tab' ||
- e.key === 'Escape' ||
- e.key === 'Enter' ||
- e.key === 'ArrowLeft' ||
- e.key === 'ArrowRight' ||
- e.key === 'Home' ||
- e.key === 'End'
- ) {
- return;
+ // Allow: navigation, selection, delete, backspace, tab, escape, enter
+ const allowedKeys = [
+ 'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
+ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
+ 'Home', 'End'
+ ];
+
+ if (allowedKeys.includes(e.key)) {
+ return; // Let these through
}
- // Handle backspace/delete
- if (e.key === 'Backspace' || e.key === 'Delete') {
- e.preventDefault();
- removeDigit();
+ // Allow Ctrl/Cmd + A, C, V, X (select all, copy, paste, cut)
+ if ((e.ctrlKey || e.metaKey) && ['a', 'c', 'v', 'x'].includes(e.key.toLowerCase())) {
return;
}
// Only allow digits 0-9
- if (/^[0-9]$/.test(e.key)) {
+ if (!/^[0-9]$/.test(e.key)) {
e.preventDefault();
- addDigit(parseInt(e.key, 10));
- return;
- }
-
- // Block everything else
- e.preventDefault();
- };
-
- // Catch input from mobile keyboards, IME, voice input, etc.
- const handleBeforeInput = (e: React.FormEvent) => {
- const inputEvent = e.nativeEvent as InputEvent;
- const data = inputEvent.data;
-
- // Always prevent default - we handle all input ourselves
- e.preventDefault();
-
- if (!data) return;
-
- // Extract only digits from the input
- const digits = data.replace(/\D/g, '');
-
- // Add each digit one at a time
- for (const char of digits) {
- addDigit(parseInt(char, 10));
}
};
- const handleFocus = () => {
- setIsFocused(true);
+ const handleFocus = (e: React.FocusEvent) => {
+ // Select all text for easy replacement
+ setTimeout(() => {
+ e.target.select();
+ }, 0);
};
const handleBlur = () => {
- setIsFocused(false);
+ // Extract digits and reparse to enforce constraints
+ const digits = extractDigits(displayValue);
+ let cents = digits ? parseInt(digits, 10) : 0;
+
// Enforce min on blur if specified
- if (min !== undefined && safeValue < min && safeValue > 0) {
- onChange(min);
+ if (min !== undefined && cents < min && cents > 0) {
+ cents = min;
+ onChange(cents);
}
+
+ // Enforce max on blur if specified
+ if (max !== undefined && cents > max) {
+ cents = max;
+ onChange(cents);
+ }
+
+ // Reformat display
+ setDisplayValue(formatCentsAsDollars(cents));
};
- // Handle paste - extract digits only
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
- const digits = pastedText.replace(/\D/g, '');
+ const digits = extractDigits(pastedText);
if (digits) {
- let newValue = parseInt(digits, 10);
- if (max !== undefined && newValue > max) {
- newValue = max;
- }
- onChange(newValue);
- }
- };
+ let cents = parseInt(digits, 10);
- // Handle drop - extract digits only
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- const droppedText = e.dataTransfer.getData('text');
- const digits = droppedText.replace(/\D/g, '');
-
- if (digits) {
- let newValue = parseInt(digits, 10);
- if (max !== undefined && newValue > max) {
- newValue = max;
+ if (max !== undefined && cents > max) {
+ cents = max;
}
- onChange(newValue);
+
+ onChange(cents);
+ setDisplayValue(formatCentsAsDollars(cents));
}
};
@@ -163,15 +145,12 @@ const CurrencyInput: React.FC = ({
ref={inputRef}
type="text"
inputMode="numeric"
- pattern="[0-9]*"
value={displayValue}
+ onChange={handleChange}
onKeyDown={handleKeyDown}
- onBeforeInput={handleBeforeInput}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
- onDrop={handleDrop}
- onChange={() => {}} // Controlled via onKeyDown/onBeforeInput
disabled={disabled}
required={required}
placeholder={placeholder}
diff --git a/frontend/src/components/ui/lumina.tsx b/frontend/src/components/ui/lumina.tsx
new file mode 100644
index 0000000..cf3f092
--- /dev/null
+++ b/frontend/src/components/ui/lumina.tsx
@@ -0,0 +1,310 @@
+/**
+ * Lumina Design System - Reusable UI Components
+ * Modern, premium design aesthetic with smooth animations and clean styling
+ */
+
+import React from 'react';
+import { LucideIcon } from 'lucide-react';
+
+// ============================================================================
+// Button Components
+// ============================================================================
+
+interface LuminaButtonProps extends React.ButtonHTMLAttributes {
+ variant?: 'primary' | 'secondary' | 'ghost';
+ size?: 'sm' | 'md' | 'lg';
+ icon?: LucideIcon;
+ iconPosition?: 'left' | 'right';
+ loading?: boolean;
+ children: React.ReactNode;
+}
+
+export const LuminaButton: React.FC = ({
+ variant = 'primary',
+ size = 'md',
+ icon: Icon,
+ iconPosition = 'right',
+ loading = false,
+ children,
+ className = '',
+ disabled,
+ ...props
+}) => {
+ const baseClasses = 'inline-flex items-center justify-center font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2';
+
+ const variantClasses = {
+ primary: 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500 shadow-sm',
+ secondary: 'bg-white text-gray-900 border border-gray-300 hover:bg-gray-50 focus:ring-indigo-500',
+ ghost: 'text-indigo-600 hover:bg-indigo-50 focus:ring-indigo-500',
+ };
+
+ const sizeClasses = {
+ sm: 'px-3 py-1.5 text-sm rounded-lg',
+ md: 'px-4 py-2.5 text-sm rounded-lg',
+ lg: 'px-6 py-3 text-base rounded-lg',
+ };
+
+ const disabledClasses = 'disabled:opacity-70 disabled:cursor-not-allowed';
+
+ return (
+
+ );
+};
+
+// ============================================================================
+// Input Components
+// ============================================================================
+
+interface LuminaInputProps extends React.InputHTMLAttributes {
+ label?: string;
+ error?: string;
+ hint?: string;
+ icon?: LucideIcon;
+}
+
+export const LuminaInput: React.FC = ({
+ label,
+ error,
+ hint,
+ icon: Icon,
+ className = '',
+ ...props
+}) => {
+ return (
+
+ {label && (
+
+ )}
+
+ {Icon && (
+
+
+
+ )}
+
+
+ {error &&
{error}
}
+ {hint && !error &&
{hint}
}
+
+ );
+};
+
+// ============================================================================
+// Card Components
+// ============================================================================
+
+interface LuminaCardProps {
+ children: React.ReactNode;
+ className?: string;
+ padding?: 'none' | 'sm' | 'md' | 'lg';
+ hover?: boolean;
+}
+
+export const LuminaCard: React.FC = ({
+ children,
+ className = '',
+ padding = 'md',
+ hover = false,
+}) => {
+ const paddingClasses = {
+ none: '',
+ sm: 'p-4',
+ md: 'p-6',
+ lg: 'p-8',
+ };
+
+ const hoverClasses = hover ? 'hover:shadow-lg hover:-translate-y-0.5 transition-all' : '';
+
+ return (
+
+ {children}
+
+ );
+};
+
+// ============================================================================
+// Badge Components
+// ============================================================================
+
+interface LuminaBadgeProps {
+ children: React.ReactNode;
+ variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
+ size?: 'sm' | 'md';
+}
+
+export const LuminaBadge: React.FC = ({
+ children,
+ variant = 'default',
+ size = 'md',
+}) => {
+ const variantClasses = {
+ default: 'bg-gray-100 text-gray-800',
+ success: 'bg-green-100 text-green-800',
+ warning: 'bg-amber-100 text-amber-800',
+ error: 'bg-red-100 text-red-800',
+ info: 'bg-blue-100 text-blue-800',
+ };
+
+ const sizeClasses = {
+ sm: 'text-xs px-2 py-0.5',
+ md: 'text-sm px-2.5 py-1',
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+// ============================================================================
+// Section Container
+// ============================================================================
+
+interface LuminaSectionProps {
+ children: React.ReactNode;
+ title?: string;
+ subtitle?: string;
+ className?: string;
+}
+
+export const LuminaSection: React.FC = ({
+ children,
+ title,
+ subtitle,
+ className = '',
+}) => {
+ return (
+
+
+ {(title || subtitle) && (
+
+ {title &&
{title}
}
+ {subtitle &&
{subtitle}
}
+
+ )}
+ {children}
+
+
+ );
+};
+
+// ============================================================================
+// Icon Box Component
+// ============================================================================
+
+interface LuminaIconBoxProps {
+ icon: LucideIcon;
+ color?: 'indigo' | 'green' | 'amber' | 'red' | 'blue';
+ size?: 'sm' | 'md' | 'lg';
+}
+
+export const LuminaIconBox: React.FC = ({
+ icon: Icon,
+ color = 'indigo',
+ size = 'md',
+}) => {
+ const colorClasses = {
+ indigo: 'bg-indigo-100 text-indigo-600',
+ green: 'bg-green-100 text-green-600',
+ amber: 'bg-amber-100 text-amber-600',
+ red: 'bg-red-100 text-red-600',
+ blue: 'bg-blue-100 text-blue-600',
+ };
+
+ const sizeClasses = {
+ sm: 'w-10 h-10',
+ md: 'w-12 h-12',
+ lg: 'w-16 h-16',
+ };
+
+ const iconSizeClasses = {
+ sm: 'w-5 h-5',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ return (
+
+
+
+ );
+};
+
+// ============================================================================
+// Feature Card Component
+// ============================================================================
+
+interface LuminaFeatureCardProps {
+ icon: LucideIcon;
+ title: string;
+ description: string;
+ onClick?: () => void;
+}
+
+export const LuminaFeatureCard: React.FC = ({
+ icon,
+ title,
+ description,
+ onClick,
+}) => {
+ return (
+
+
+
+
{title}
+
{description}
+
+
+ );
+};
+
+// ============================================================================
+// Loading Spinner
+// ============================================================================
+
+interface LuminaSpinnerProps {
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export const LuminaSpinner: React.FC = ({
+ size = 'md',
+ className = '',
+}) => {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-8 h-8',
+ lg: 'w-12 h-12',
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/hooks/useBooking.ts b/frontend/src/hooks/useBooking.ts
index 6a6fe1b..aa5c221 100644
--- a/frontend/src/hooks/useBooking.ts
+++ b/frontend/src/hooks/useBooking.ts
@@ -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({
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({
+ 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({
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({
+ 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) => {
diff --git a/frontend/src/hooks/useBusiness.ts b/frontend/src/hooks/useBusiness.ts
index 561321c..39ad535 100644
--- a/frontend/src/hooks/useBusiness.ts
+++ b/frontend/src/hooks/useBusiness.ts
@@ -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;
diff --git a/frontend/src/hooks/useServices.ts b/frontend/src/hooks/useServices.ts
index 79af636..41a7736 100644
--- a/frontend/src/hooks/useServices.ts
+++ b/frontend/src/hooks/useServices.ts
@@ -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 = {
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 = {};
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;
},
diff --git a/frontend/src/hooks/useSites.ts b/frontend/src/hooks/useSites.ts
index 5e66a9d..ba019ec 100644
--- a/frontend/src/hooks/useSites.ts
+++ b/frontend/src/hooks/useSites.ts
@@ -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'],
diff --git a/frontend/src/hooks/useTimeBlocks.ts b/frontend/src/hooks/useTimeBlocks.ts
index bcf7f9b..bc02f7e 100644
--- a/frontend/src/hooks/useTimeBlocks.ts
+++ b/frontend/src/hooks/useTimeBlocks.ts
@@ -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,
diff --git a/frontend/src/layouts/SettingsLayout.tsx b/frontend/src/layouts/SettingsLayout.tsx
index 2cd6652..da7b70f 100644
--- a/frontend/src/layouts/SettingsLayout.tsx
+++ b/frontend/src/layouts/SettingsLayout.tsx
@@ -21,6 +21,7 @@ import {
CreditCard,
AlertTriangle,
Calendar,
+ Clock,
} from 'lucide-react';
import {
SettingsSidebarSection,
@@ -109,6 +110,12 @@ const SettingsLayout: React.FC = () => {
label={t('settings.booking.title', 'Booking')}
description={t('settings.booking.description', 'Booking URL, redirects')}
/>
+
{/* Branding Section */}
diff --git a/frontend/src/pages/BookingFlow.tsx b/frontend/src/pages/BookingFlow.tsx
new file mode 100644
index 0000000..e21acde
--- /dev/null
+++ b/frontend/src/pages/BookingFlow.tsx
@@ -0,0 +1,260 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { ServiceSelection } from '../components/booking/ServiceSelection';
+import { DateTimeSelection } from '../components/booking/DateTimeSelection';
+import { AuthSection, User } from '../components/booking/AuthSection';
+import { PaymentSection } from '../components/booking/PaymentSection';
+import { Confirmation } from '../components/booking/Confirmation';
+import { Steps } from '../components/booking/Steps';
+import { ArrowLeft, ArrowRight } from 'lucide-react';
+import { PublicService } from '../hooks/useBooking';
+
+interface BookingState {
+ step: number;
+ service: PublicService | null;
+ date: Date | null;
+ timeSlot: string | null;
+ user: User | null;
+ paymentMethod: string | null;
+}
+
+// Storage key for booking state
+const BOOKING_STATE_KEY = 'booking_state';
+
+// Load booking state from sessionStorage
+const loadBookingState = (): Partial => {
+ try {
+ const saved = sessionStorage.getItem(BOOKING_STATE_KEY);
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ // Convert date string back to Date object
+ if (parsed.date) {
+ parsed.date = new Date(parsed.date);
+ }
+ return parsed;
+ }
+ } catch (e) {
+ console.error('Failed to load booking state:', e);
+ }
+ return {};
+};
+
+// Save booking state to sessionStorage
+const saveBookingState = (state: BookingState) => {
+ try {
+ sessionStorage.setItem(BOOKING_STATE_KEY, JSON.stringify(state));
+ } catch (e) {
+ console.error('Failed to save booking state:', e);
+ }
+};
+
+export const BookingFlow: React.FC = () => {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ // Get step from URL or default to 1
+ const stepFromUrl = parseInt(searchParams.get('step') || '1');
+
+ // Load saved state from sessionStorage
+ const savedState = loadBookingState();
+
+ const [bookingState, setBookingState] = useState({
+ step: stepFromUrl,
+ service: savedState.service || null,
+ date: savedState.date || null,
+ timeSlot: savedState.timeSlot || null,
+ user: savedState.user || null,
+ paymentMethod: savedState.paymentMethod || null
+ });
+
+ // Update URL when step changes
+ useEffect(() => {
+ setSearchParams({ step: bookingState.step.toString() });
+ }, [bookingState.step, setSearchParams]);
+
+ // Save booking state to sessionStorage whenever it changes
+ useEffect(() => {
+ saveBookingState(bookingState);
+ }, [bookingState]);
+
+ // Redirect to step 1 if on step > 1 but no service selected
+ useEffect(() => {
+ if (bookingState.step > 1 && !bookingState.service) {
+ setBookingState(prev => ({ ...prev, step: 1 }));
+ }
+ }, [bookingState.step, bookingState.service]);
+
+ const nextStep = () => setBookingState(prev => ({ ...prev, step: prev.step + 1 }));
+ const prevStep = () => {
+ if (bookingState.step === 1) {
+ navigate(-1); // Go back to previous page
+ } else {
+ setBookingState(prev => ({ ...prev, step: prev.step - 1 }));
+ }
+ };
+
+ // Handlers
+ const handleServiceSelect = (service: PublicService) => {
+ setBookingState(prev => ({ ...prev, service }));
+ setTimeout(nextStep, 300);
+ };
+
+ const handleDateChange = (date: Date) => {
+ setBookingState(prev => ({ ...prev, date }));
+ };
+
+ const handleTimeChange = (timeSlot: string) => {
+ setBookingState(prev => ({ ...prev, timeSlot }));
+ };
+
+ const handleLogin = (user: User) => {
+ setBookingState(prev => ({ ...prev, user }));
+ nextStep();
+ };
+
+ const handlePaymentComplete = () => {
+ nextStep();
+ };
+
+ // Reusable navigation footer component
+ const StepNavigation: React.FC<{
+ showBack?: boolean;
+ showContinue?: boolean;
+ continueDisabled?: boolean;
+ continueLabel?: string;
+ onContinue?: () => void;
+ }> = ({ showBack = true, showContinue = false, continueDisabled = false, continueLabel = 'Continue', onContinue }) => (
+
+ {showBack && (
+
+ )}
+ {showContinue && (
+
+ )}
+
+ );
+
+ const renderStep = () => {
+ switch (bookingState.step) {
+ case 1:
+ return (
+
+
+
+
+ );
+ case 2:
+ return (
+
+
+
+
+ );
+ case 3:
+ return (
+
+ );
+ case 4:
+ return bookingState.service ? (
+
+ ) : null;
+ case 5:
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ {/* Progress Stepper */}
+ {bookingState.step < 5 && (
+
+
+
+ )}
+
+ {/* Booking Summary (steps 2-4) */}
+ {bookingState.step > 1 && bookingState.step < 5 && (
+
+ {bookingState.service && (
+
+ Service:
+ {bookingState.service.name} (${(bookingState.service.price_cents / 100).toFixed(2)})
+
+ )}
+ {bookingState.date && bookingState.timeSlot && (
+ <>
+
+
+ Time:
+ {bookingState.date.toLocaleDateString()} at {bookingState.timeSlot}
+
+ >
+ )}
+
+ )}
+
+ {/* Main Content */}
+
+ {renderStep()}
+
+
+
+ );
+};
+
+export default BookingFlow;
diff --git a/frontend/src/pages/OwnerScheduler.tsx b/frontend/src/pages/OwnerScheduler.tsx
index 1f23b78..de52f44 100644
--- a/frontend/src/pages/OwnerScheduler.tsx
+++ b/frontend/src/pages/OwnerScheduler.tsx
@@ -1356,8 +1356,8 @@ const OwnerScheduler: React.FC = ({ user, business }) => {
// Separate business and resource blocks
const businessBlocks = dateBlocks.filter(b => b.resource_id === null);
- const hasBusinessHard = businessBlocks.some(b => b.block_type === 'HARD');
- const hasBusinessSoft = businessBlocks.some(b => b.block_type === 'SOFT');
+ // Only mark as closed if there's an all-day BUSINESS_CLOSED block
+ const isBusinessClosed = businessBlocks.some(b => b.all_day && b.purpose === 'BUSINESS_CLOSED');
// Group resource blocks by resource - maintain resource order
const resourceBlocksByResource = resources.map(resource => {
@@ -1370,11 +1370,10 @@ const OwnerScheduler: React.FC = ({ user, business }) => {
};
}).filter(rb => rb.blocks.length > 0);
- // Determine background color - only business blocks affect the whole cell now
+ // Determine background color - only show gray for fully closed days
const getBgClass = () => {
if (date && date.getMonth() !== viewDate.getMonth()) return 'bg-gray-100 dark:bg-gray-800/70 opacity-50';
- if (hasBusinessHard) return 'bg-red-50 dark:bg-red-900/20';
- if (hasBusinessSoft) return 'bg-yellow-50 dark:bg-yellow-900/20';
+ if (isBusinessClosed) return 'bg-gray-100 dark:bg-gray-700/50';
if (date) return 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800';
return 'bg-gray-50 dark:bg-gray-800/50';
};
@@ -1396,18 +1395,6 @@ const OwnerScheduler: React.FC = ({ user, business }) => {
}`}>
{date.getDate()}
- {hasBusinessHard && (
- b.block_type === 'HARD')?.title}>
- B
-
- )}
- {!hasBusinessHard && hasBusinessSoft && (
- b.block_type === 'SOFT')?.title}>
- B
-
- )}
-