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

@@ -112,6 +112,7 @@ const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates'));
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -126,6 +127,7 @@ const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'))
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
@@ -349,6 +351,7 @@ const AppContent: React.FC = () => {
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<PublicPage />} />
<Route path="/book" element={<BookingFlow />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
@@ -889,6 +892,7 @@ const AppContent: React.FC = () => {
<Route path="branding" element={<BrandingSettings />} />
<Route path="resource-types" element={<ResourceTypesSettings />} />
<Route path="booking" element={<BookingSettings />} />
<Route path="business-hours" element={<BusinessHoursSettings />} />
<Route path="email-templates" element={<EmailTemplates />} />
<Route path="custom-domains" element={<CustomDomainsSettings />} />
<Route path="api" element={<ApiSettings />} />

View File

@@ -25,6 +25,7 @@ export interface PlatformBusiness {
owner: PlatformBusinessOwner | null;
max_users: number;
max_resources: number;
max_pages: number;
contact_email?: string;
phone?: string;
// Platform permissions
@@ -51,6 +52,7 @@ export interface PlatformBusiness {
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
}
export interface PlatformBusinessUpdate {
@@ -59,6 +61,7 @@ export interface PlatformBusinessUpdate {
subscription_tier?: string;
max_users?: number;
max_resources?: number;
max_pages?: number;
// Platform permissions
can_manage_oauth_credentials?: boolean;
can_accept_payments?: boolean;
@@ -83,10 +86,10 @@ export interface PlatformBusinessUpdate {
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
can_process_refunds?: boolean;
can_create_packages?: boolean;
can_use_email_templates?: boolean;
can_customize_booking_page?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
@@ -100,6 +103,7 @@ export interface PlatformBusinessCreate {
is_active?: boolean;
max_users?: number;
max_resources?: number;
max_pages?: number;
contact_email?: string;
phone?: string;
can_manage_oauth_credentials?: boolean;

View File

@@ -17,6 +17,7 @@ import {
Plug,
FileSignature,
CalendarOff,
LayoutTemplate,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
@@ -119,6 +120,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
{!isStaff && (
@@ -152,6 +154,13 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{/* Manage Section - Staff+ */}
{canViewManagementPages && (
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
<SidebarItem
to="/site-editor"
icon={LayoutTemplate}
label={t('nav.siteBuilder', 'Site Builder')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/customers"
icon={Users}

View File

@@ -0,0 +1,361 @@
import React, { useState } from 'react';
import { Mail, Lock, User as UserIcon, ArrowRight, Shield } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../../api/client';
export interface User {
id: string | number;
name: string;
email: string;
}
interface AuthSectionProps {
onLogin: (user: User) => void;
}
export const AuthSection: React.FC<AuthSectionProps> = ({ onLogin }) => {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [loading, setLoading] = useState(false);
// Email verification states
const [needsVerification, setNeedsVerification] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const [verifyingCode, setVerifyingCode] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await api.post('/auth/login/', {
username: email,
password: password
});
const user: User = {
id: response.data.user.id,
email: response.data.user.email,
name: response.data.user.full_name || response.data.user.email,
};
toast.success('Welcome back!');
onLogin(user);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Login failed');
} finally {
setLoading(false);
}
};
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
// Validate passwords match
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
// Validate password length
if (password.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
// Send verification email
await api.post('/auth/send-verification/', {
email: email,
first_name: firstName,
last_name: lastName
});
toast.success('Verification code sent to your email!');
setNeedsVerification(true);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Failed to send verification code');
} finally {
setLoading(false);
}
};
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault();
setVerifyingCode(true);
try {
// Verify code and create account
const response = await api.post('/auth/verify-and-register/', {
email: email,
first_name: firstName,
last_name: lastName,
password: password,
verification_code: verificationCode
});
const user: User = {
id: response.data.user.id,
email: response.data.user.email,
name: response.data.user.full_name || response.data.user.name,
};
toast.success('Account created successfully!');
onLogin(user);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Verification failed');
} finally {
setVerifyingCode(false);
}
};
const handleResendCode = async () => {
setLoading(true);
try {
await api.post('/auth/send-verification/', {
email: email,
first_name: firstName,
last_name: lastName
});
toast.success('New code sent!');
} catch (error: any) {
toast.error('Failed to resend code');
} finally {
setLoading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
if (isLogin) {
handleLogin(e);
} else {
handleSignup(e);
}
};
// Show verification step for new customers
if (needsVerification && !isLogin) {
return (
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/50 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Verify Your Email</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">
We've sent a 6-digit code to <span className="font-medium text-gray-900 dark:text-white">{email}</span>
</p>
</div>
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
<form onSubmit={handleVerifyCode} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Verification Code
</label>
<input
type="text"
required
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="block w-full px-4 py-3 text-center text-2xl font-mono tracking-widest border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="000000"
maxLength={6}
autoFocus
/>
</div>
<button
type="submit"
disabled={verifyingCode || verificationCode.length !== 6}
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
>
{verifyingCode ? (
<span className="animate-pulse">Verifying...</span>
) : (
<>
Verify & Continue
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-6 text-center space-y-2">
<button
type="button"
onClick={handleResendCode}
disabled={loading}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 disabled:opacity-50"
>
Resend Code
</button>
<div>
<button
type="button"
onClick={() => {
setNeedsVerification(false);
setVerificationCode('');
}}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Change email address
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{isLogin ? 'Welcome Back' : 'Create Account'}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">
{isLogin
? 'Sign in to access your bookings and history.'
: 'Join us to book your first premium service.'}
</p>
</div>
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
<form onSubmit={handleSubmit} className="space-y-5">
{!isLogin && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="text"
required={!isLogin}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="John"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name</label>
<input
type="text"
required={!isLogin}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="block w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="Doe"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="password"
required
minLength={isLogin ? undefined : 8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="••••••••"
/>
</div>
{!isLogin && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
)}
</div>
{!isLogin && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={`block w-full pl-10 pr-3 py-2.5 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors ${
confirmPassword && password !== confirmPassword
? 'border-red-300 dark:border-red-500'
: 'border-gray-300 dark:border-gray-600'
}`}
placeholder="••••••••"
/>
</div>
{confirmPassword && password !== confirmPassword && (
<p className="mt-1 text-xs text-red-500">Passwords do not match</p>
)}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
>
{loading ? (
<span className="animate-pulse">Processing...</span>
) : (
<>
{isLogin ? 'Sign In' : 'Create Account'}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setConfirmPassword('');
setFirstName('');
setLastName('');
}}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300"
>
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -33,29 +33,32 @@ export const BookingWidget: React.FC<BookingWidgetProps> = ({
};
return (
<div className="booking-widget p-6 bg-white rounded-lg shadow-md max-w-md mx-auto text-left">
<h2 className="text-2xl font-bold mb-2" style={{ color: accentColor }}>{headline}</h2>
<p className="text-gray-600 mb-6">{subheading}</p>
<div className="booking-widget p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-900/30 max-w-md mx-auto text-left border border-gray-100 dark:border-gray-700">
<h2 className="text-2xl font-bold mb-2 text-indigo-600 dark:text-indigo-400">{headline}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">{subheading}</p>
<div className="space-y-4 mb-6">
{services?.length === 0 && <p>No services available.</p>}
{services?.length === 0 && <p className="text-gray-600 dark:text-gray-400">No services available.</p>}
{services?.map((service: any) => (
<div
key={service.id}
className={`p-4 border rounded cursor-pointer transition-colors ${selectedService?.id === service.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-300'}`}
<div
key={service.id}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedService?.id === service.id
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 dark:border-indigo-400'
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 bg-white dark:bg-gray-700/50'
}`}
onClick={() => setSelectedService(service)}
>
<h3 className="font-semibold">{service.name}</h3>
<p className="text-sm text-gray-500">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
<h3 className="font-semibold text-gray-900 dark:text-white">{service.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
</div>
))}
</div>
<button
<button
onClick={handleBook}
disabled={!selectedService}
className="w-full py-2 px-4 rounded text-white font-medium disabled:opacity-50 transition-opacity"
style={{ backgroundColor: accentColor }}
className="w-full py-3 px-4 rounded-lg bg-indigo-600 dark:bg-indigo-500 text-white font-semibold disabled:opacity-50 hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all shadow-sm hover:shadow-md"
>
{buttonLabel}
</button>

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { CheckCircle, Calendar, MapPin, ArrowRight } from 'lucide-react';
import { PublicService } from '../../hooks/useBooking';
import { User } from './AuthSection';
interface BookingState {
step: number;
service: PublicService | null;
date: Date | null;
timeSlot: string | null;
user: User | null;
paymentMethod: string | null;
}
interface ConfirmationProps {
booking: BookingState;
}
export const Confirmation: React.FC<ConfirmationProps> = ({ booking }) => {
const navigate = useNavigate();
if (!booking.service || !booking.date || !booking.timeSlot) return null;
// Generate a pseudo-random booking reference based on timestamp
const bookingRef = `BK-${Date.now().toString().slice(-6)}`;
return (
<div className="text-center max-w-2xl mx-auto py-10">
<div className="mb-6 flex justify-center">
<div className="h-24 w-24 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">Booking Confirmed!</h2>
<p className="text-lg text-gray-600 dark:text-gray-300 mb-8">
Thank you, {booking.user?.name}. Your appointment has been successfully scheduled.
</p>
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden text-left">
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<h3 className="font-semibold text-gray-900 dark:text-white">Booking Details</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Ref: #{bookingRef}</p>
</div>
<div className="p-6 space-y-4">
<div className="flex items-start">
<div className="h-12 w-12 rounded-lg bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center flex-shrink-0 mr-4">
{booking.service.photos && booking.service.photos.length > 0 ? (
<img src={booking.service.photos[0]} className="h-12 w-12 rounded-lg object-cover" alt="" />
) : (
<div className="h-12 w-12 rounded-lg bg-indigo-200 dark:bg-indigo-800" />
)}
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">{booking.service.name}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">{booking.service.duration} minutes</p>
</div>
<div className="ml-auto text-right">
<p className="font-medium text-gray-900 dark:text-white">${(booking.service.price_cents / 100).toFixed(2)}</p>
{booking.service.deposit_amount_cents && booking.service.deposit_amount_cents > 0 && (
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Deposit Paid</p>
)}
</div>
</div>
<div className="border-t border-gray-100 dark:border-gray-700 pt-4 flex flex-col sm:flex-row sm:justify-between gap-4">
<div className="flex items-center text-gray-700 dark:text-gray-300">
<Calendar className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
<div>
<p className="text-sm font-medium">Date & Time</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{booking.date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })} at {booking.timeSlot}
</p>
</div>
</div>
<div className="flex items-center text-gray-700 dark:text-gray-300">
<MapPin className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
<div>
<p className="text-sm font-medium">Location</p>
<p className="text-sm text-gray-500 dark:text-gray-400">See confirmation email</p>
</div>
</div>
</div>
</div>
</div>
<p className="mt-6 text-sm text-gray-500 dark:text-gray-400">
A confirmation email has been sent to {booking.user?.email}.
</p>
<div className="mt-8 flex justify-center space-x-4">
<button
onClick={() => navigate('/')}
className="flex items-center px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-colors shadow-lg"
>
Done
<ArrowRight className="w-4 h-4 ml-2" />
</button>
<button
onClick={() => {
// Clear booking state and start fresh
sessionStorage.removeItem('booking_state');
navigate('/book');
}}
className="px-6 py-3 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Book Another
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,276 @@
import React, { useMemo } from 'react';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2, XCircle } from 'lucide-react';
import { usePublicAvailability, usePublicBusinessHours } from '../../hooks/useBooking';
import { formatTimeForDisplay, getTimezoneAbbreviation, getUserTimezone } from '../../utils/dateUtils';
interface DateTimeSelectionProps {
serviceId?: number;
selectedDate: Date | null;
selectedTimeSlot: string | null;
onDateChange: (date: Date) => void;
onTimeChange: (time: string) => void;
}
export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
serviceId,
selectedDate,
selectedTimeSlot,
onDateChange,
onTimeChange
}) => {
const today = new Date();
const [currentMonth, setCurrentMonth] = React.useState(today.getMonth());
const [currentYear, setCurrentYear] = React.useState(today.getFullYear());
// Calculate date range for business hours query (current month view)
const { startDate, endDate } = useMemo(() => {
const start = new Date(currentYear, currentMonth, 1);
const end = new Date(currentYear, currentMonth + 1, 0);
return {
startDate: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-01`,
endDate: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
};
}, [currentMonth, currentYear]);
// Fetch business hours for the month
const { data: businessHours, isLoading: businessHoursLoading } = usePublicBusinessHours(startDate, endDate);
// Create a map of dates to their open status
const openDaysMap = useMemo(() => {
const map = new Map<string, boolean>();
if (businessHours?.dates) {
businessHours.dates.forEach(day => {
map.set(day.date, day.is_open);
});
}
return map;
}, [businessHours]);
// Format selected date for API query (YYYY-MM-DD)
const dateString = selectedDate
? `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
: undefined;
// Fetch availability when both serviceId and date are set
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(serviceId, dateString);
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
const handlePrevMonth = () => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
};
const handleNextMonth = () => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
};
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const monthName = new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long' });
const isSelected = (day: number) => {
return selectedDate?.getDate() === day &&
selectedDate?.getMonth() === currentMonth &&
selectedDate?.getFullYear() === currentYear;
};
const isPast = (day: number) => {
const d = new Date(currentYear, currentMonth, day);
const now = new Date();
now.setHours(0, 0, 0, 0);
return d < now;
};
const isClosed = (day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
// If we have business hours data, use it. Otherwise default to open (except past dates)
if (openDaysMap.size > 0) {
return openDaysMap.get(dateStr) === false;
}
return false;
};
const isDisabled = (day: number) => {
return isPast(day) || isClosed(day);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Calendar Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<CalendarIcon className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
Select Date
</h3>
<div className="flex space-x-2">
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
<ChevronLeft className="w-5 h-5" />
</button>
<span className="font-medium text-gray-900 dark:text-white w-32 text-center">
{monthName} {currentYear}
</span>
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-2 mb-2 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
<div>Sun</div><div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div><div>Fri</div><div>Sat</div>
</div>
{businessHoursLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
) : (
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
<div key={`empty-${i}`} />
))}
{days.map((day) => {
const past = isPast(day);
const closed = isClosed(day);
const disabled = isDisabled(day);
const selected = isSelected(day);
return (
<button
key={day}
disabled={disabled}
onClick={() => {
const newDate = new Date(currentYear, currentMonth, day);
onDateChange(newDate);
}}
className={`
h-10 w-10 rounded-full flex items-center justify-center text-sm font-medium transition-all relative
${selected
? 'bg-indigo-600 dark:bg-indigo-500 text-white shadow-md'
: closed
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: past
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400'
}
`}
title={closed ? 'Business closed' : past ? 'Past date' : undefined}
>
{day}
</button>
);
})}
</div>
)}
{/* Legend */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-gray-100 dark:bg-gray-700"></div>
<span>Closed</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-indigo-600 dark:bg-indigo-500"></div>
<span>Selected</span>
</div>
</div>
</div>
{/* Time Slots Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm flex flex-col">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Available Time Slots</h3>
{!selectedDate ? (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
Please select a date first
</div>
) : availabilityLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
) : isError ? (
<div className="flex-1 flex flex-col items-center justify-center text-red-500 dark:text-red-400">
<XCircle className="w-12 h-12 mb-3" />
<p className="font-medium">Failed to load availability</p>
<p className="text-sm mt-1 text-gray-500 dark:text-gray-400">
{error instanceof Error ? error.message : 'Please try again'}
</p>
</div>
) : availability?.is_open === false ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<XCircle className="w-12 h-12 mb-3 text-gray-300 dark:text-gray-600" />
<p className="font-medium">Business Closed</p>
<p className="text-sm mt-1">Please select another date</p>
</div>
) : availability?.slots && availability.slots.length > 0 ? (
<>
{(() => {
// Determine which timezone to display based on business settings
const displayTimezone = availability.timezone_display_mode === 'viewer'
? getUserTimezone()
: availability.business_timezone || getUserTimezone();
const tzAbbrev = getTimezoneAbbreviation(displayTimezone);
return (
<>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
{availability.business_hours && (
<>Business hours: {availability.business_hours.start} - {availability.business_hours.end} </>
)}
Times shown in {tzAbbrev}
</p>
<div className="grid grid-cols-2 gap-3">
{availability.slots.map((slot) => {
// Format time in the appropriate timezone
const displayTime = formatTimeForDisplay(
slot.time,
availability.timezone_display_mode === 'viewer' ? null : availability.business_timezone
);
return (
<button
key={slot.time}
disabled={!slot.available}
onClick={() => onTimeChange(displayTime)}
className={`
py-3 px-4 rounded-lg text-sm font-medium border transition-all duration-200
${!slot.available
? 'bg-gray-50 dark:bg-gray-700 text-gray-400 dark:text-gray-500 border-gray-100 dark:border-gray-600 cursor-not-allowed'
: selectedTimeSlot === displayTime
? 'bg-indigo-600 dark:bg-indigo-500 text-white border-indigo-600 dark:border-indigo-500 shadow-sm'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-400'
}
`}
>
{displayTime}
{!slot.available && <span className="block text-[10px] font-normal">Booked</span>}
</button>
);
})}
</div>
</>
);
})()}
</>
) : !serviceId ? (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
Please select a service first
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
No available time slots for this date
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,134 @@
import React, { useState, useRef, useEffect } from 'react';
import { MessageCircle, X, Send, Sparkles } from 'lucide-react';
import { BookingState, ChatMessage } from './types';
// TODO: Implement Gemini service
const sendMessageToGemini = async (message: string, bookingState: BookingState): Promise<string> => {
// Mock implementation - replace with actual Gemini API call
return "I'm here to help you book your appointment. Please use the booking form above.";
};
interface GeminiChatProps {
currentBookingState: BookingState;
}
export const GeminiChat: React.FC<GeminiChatProps> = ({ currentBookingState }) => {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'model', text: 'Hi! I can help you choose a service or answer questions about booking.' }
]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, isOpen]);
const handleSend = async () => {
if (!inputText.trim() || isLoading) return;
const userMsg: ChatMessage = { role: 'user', text: inputText };
setMessages(prev => [...prev, userMsg]);
setInputText('');
setIsLoading(true);
try {
const responseText = await sendMessageToGemini(inputText, messages, currentBookingState);
setMessages(prev => [...prev, { role: 'model', text: responseText }]);
} catch (error) {
setMessages(prev => [...prev, { role: 'model', text: "Sorry, I'm having trouble connecting." }]);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
{/* Chat Window */}
{isOpen && (
<div className="bg-white w-80 sm:w-96 h-[500px] rounded-2xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden mb-4 animate-in slide-in-from-bottom-10 fade-in duration-200">
<div className="bg-indigo-600 p-4 flex justify-between items-center text-white">
<div className="flex items-center space-x-2">
<Sparkles className="w-4 h-4" />
<span className="font-semibold">Lumina Assistant</span>
</div>
<button onClick={() => setIsOpen(false)} className="hover:bg-indigo-500 rounded-full p-1 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 scrollbar-hide">
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`
max-w-[80%] px-4 py-2 rounded-2xl text-sm
${msg.role === 'user'
? 'bg-indigo-600 text-white rounded-br-none'
: 'bg-white text-gray-800 border border-gray-200 shadow-sm rounded-bl-none'
}
`}
>
{msg.text}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white px-4 py-2 rounded-2xl rounded-bl-none border border-gray-200 shadow-sm">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-3 bg-white border-t border-gray-100">
<form
onSubmit={(e) => { e.preventDefault(); handleSend(); }}
className="flex items-center gap-2"
>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Ask about services..."
className="flex-1 px-4 py-2 rounded-full border border-gray-300 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 text-sm"
/>
<button
type="submit"
disabled={isLoading || !inputText.trim()}
className="p-2 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="w-4 h-4" />
</button>
</form>
</div>
</div>
)}
{/* Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`
p-4 rounded-full shadow-xl transition-all duration-300 flex items-center justify-center
${isOpen ? 'bg-gray-800 rotate-90 scale-0' : 'bg-indigo-600 hover:bg-indigo-700 scale-100'}
`}
style={{display: isOpen ? 'none' : 'flex'}}
>
<MessageCircle className="w-6 h-6 text-white" />
</button>
</div>
);
};

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { PublicService } from '../../hooks/useBooking';
import { CreditCard, ShieldCheck, Lock } from 'lucide-react';
interface PaymentSectionProps {
service: PublicService;
onPaymentComplete: () => void;
}
export const PaymentSection: React.FC<PaymentSectionProps> = ({ service, onPaymentComplete }) => {
const [processing, setProcessing] = useState(false);
const [cardNumber, setCardNumber] = useState('');
const [expiry, setExpiry] = useState('');
const [cvc, setCvc] = useState('');
// Convert cents to dollars
const price = service.price_cents / 100;
const deposit = (service.deposit_amount_cents || 0) / 100;
// Auto-format card number
const handleCardInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let val = e.target.value.replace(/\D/g, '');
val = val.substring(0, 16);
val = val.replace(/(\d{4})/g, '$1 ').trim();
setCardNumber(val);
};
const handlePayment = (e: React.FormEvent) => {
e.preventDefault();
setProcessing(true);
// Simulate Stripe Payment Intent & Processing
setTimeout(() => {
setProcessing(false);
onPaymentComplete();
}, 2000);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Payment Details Column */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<CreditCard className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
Card Details
</h3>
<div className="flex space-x-2">
{/* Mock Card Icons */}
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
</div>
</div>
<form id="payment-form" onSubmit={handlePayment} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Number</label>
<input
type="text"
required
value={cardNumber}
onChange={handleCardInput}
placeholder="0000 0000 0000 0000"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expiry Date</label>
<input
type="text"
required
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
placeholder="MM / YY"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">CVC</label>
<div className="relative">
<input
type="text"
required
value={cvc}
onChange={(e) => setCvc(e.target.value)}
placeholder="123"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
<Lock className="w-4 h-4 text-gray-400 dark:text-gray-500 absolute right-3 top-3.5" />
</div>
</div>
</div>
<div className="mt-4 flex items-start p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg">
<ShieldCheck className="w-5 h-5 text-indigo-600 dark:text-indigo-400 mt-0.5 mr-3 flex-shrink-0" />
<p className="text-sm text-indigo-800 dark:text-indigo-200">
Your payment is secure. We use Stripe to process your payment. {deposit > 0 ? <>A deposit of <strong>${deposit.toFixed(2)}</strong> will be charged now.</> : <>Full payment will be collected at your appointment.</>}
</p>
</div>
</form>
</div>
</div>
{/* Summary Column */}
<div className="lg:col-span-1">
<div className="bg-gray-50 dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 sticky top-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Payment Summary</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Service Total</span>
<span>${price.toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Tax (Estimated)</span>
<span>$0.00</span>
</div>
<div className="border-t border-gray-200 dark:border-gray-600 my-2 pt-2"></div>
<div className="flex justify-between items-center text-lg font-bold text-gray-900 dark:text-white">
<span>Total</span>
<span>${price.toFixed(2)}</span>
</div>
</div>
{deposit > 0 ? (
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">Due Now (Deposit)</span>
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${deposit.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-sm text-gray-500 dark:text-gray-400">
<span>Due at appointment</span>
<span>${(price - deposit).toFixed(2)}</span>
</div>
</div>
) : (
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-900 dark:text-white">Due at appointment</span>
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${price.toFixed(2)}</span>
</div>
</div>
)}
<button
type="submit"
form="payment-form"
disabled={processing}
className="w-full mt-6 py-3 px-4 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg font-semibold shadow-md hover:bg-indigo-700 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-75 disabled:cursor-not-allowed transition-all"
>
{processing ? 'Processing...' : deposit > 0 ? `Pay $${deposit.toFixed(2)} Deposit` : 'Confirm Booking'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Clock, DollarSign, Loader2 } from 'lucide-react';
import { usePublicServices, usePublicBusinessInfo, PublicService } from '../../hooks/useBooking';
interface ServiceSelectionProps {
selectedService: PublicService | null;
onSelect: (service: PublicService) => void;
}
export const ServiceSelection: React.FC<ServiceSelectionProps> = ({ selectedService, onSelect }) => {
const { data: services, isLoading: servicesLoading } = usePublicServices();
const { data: businessInfo, isLoading: businessLoading } = usePublicBusinessInfo();
const isLoading = servicesLoading || businessLoading;
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
);
}
const heading = businessInfo?.service_selection_heading || 'Choose your experience';
const subheading = businessInfo?.service_selection_subheading || 'Select a service to begin your booking.';
// Get first photo as image, or use a placeholder
const getServiceImage = (service: PublicService): string | null => {
if (service.photos && service.photos.length > 0) {
return service.photos[0];
}
return null;
};
// Format price from cents to dollars
const formatPrice = (cents: number): string => {
return (cents / 100).toFixed(2);
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{heading}</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">{subheading}</p>
</div>
{(!services || services.length === 0) && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No services available at this time.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{services?.map((service) => {
const image = getServiceImage(service);
const hasImage = !!image;
return (
<div
key={service.id}
onClick={() => onSelect(service)}
className={`
relative overflow-hidden rounded-xl border-2 transition-all duration-200 cursor-pointer group
${selectedService?.id === service.id
? 'border-indigo-600 dark:border-indigo-400 bg-indigo-50/50 dark:bg-indigo-900/20 ring-2 ring-indigo-600 dark:ring-indigo-400 ring-offset-2 dark:ring-offset-gray-900'
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-lg bg-white dark:bg-gray-800'}
`}
>
<div className="flex h-full min-h-[140px]">
{hasImage && (
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
<img
src={image}
alt={service.name}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
<div className={`${hasImage ? 'w-2/3' : 'w-full'} p-5 flex flex-col justify-between`}>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{service.name}
</h3>
{service.description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{service.description}
</p>
)}
</div>
<div className="mt-4 flex items-center justify-between text-sm">
<div className="flex items-center text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4 mr-1.5" />
{service.duration} mins
</div>
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
<DollarSign className="w-4 h-4" />
{formatPrice(service.price_cents)}
</div>
</div>
{service.deposit_amount_cents && service.deposit_amount_cents > 0 && (
<div className="mt-2 text-xs text-indigo-600 dark:text-indigo-400 font-medium">
Deposit required: ${formatPrice(service.deposit_amount_cents)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Check } from 'lucide-react';
interface StepsProps {
currentStep: number;
}
const steps = [
{ id: 1, name: 'Service' },
{ id: 2, name: 'Date & Time' },
{ id: 3, name: 'Account' },
{ id: 4, name: 'Payment' },
{ id: 5, name: 'Done' },
];
export const Steps: React.FC<StepsProps> = ({ currentStep }) => {
return (
<nav aria-label="Progress">
<ol role="list" className="flex items-center">
{steps.map((step, stepIdx) => (
<li key={step.name} className={`${stepIdx !== steps.length - 1 ? 'pr-8 sm:pr-20' : ''} relative`}>
{step.id < currentStep ? (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-indigo-600 dark:bg-indigo-500" />
</div>
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
<Check className="h-5 w-5 text-white" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
) : step.id === currentStep ? (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
</div>
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-indigo-600 dark:border-indigo-400 bg-white dark:bg-gray-800" aria-current="step">
<span className="h-2.5 w-2.5 rounded-full bg-indigo-600 dark:bg-indigo-400" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
) : (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
</div>
<a href="#" className="group relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500">
<span className="h-2.5 w-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-gray-600" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
)}
<div className="absolute -bottom-6 left-1/2 transform -translate-x-1/2 w-max text-xs font-medium text-gray-500 dark:text-gray-400">
{step.name}
</div>
</li>
))}
</ol>
</nav>
);
};

View File

@@ -0,0 +1,61 @@
import { Service, TimeSlot } from './types';
// Mock services for booking flow
// TODO: In production, these should be fetched from the API
export const SERVICES: Service[] = [
{
id: 's1',
name: 'Rejuvenating Facial',
description: 'A 60-minute deep cleansing and hydrating facial treatment.',
durationMin: 60,
price: 120,
deposit: 30,
category: 'Skincare',
image: 'https://picsum.photos/400/300?random=1'
},
{
id: 's2',
name: 'Deep Tissue Massage',
description: 'Therapeutic massage focusing on realigning deeper layers of muscles.',
durationMin: 90,
price: 150,
deposit: 50,
category: 'Massage',
image: 'https://picsum.photos/400/300?random=2'
},
{
id: 's3',
name: 'Executive Haircut',
description: 'Precision haircut with wash, style, and hot towel finish.',
durationMin: 45,
price: 65,
deposit: 15,
category: 'Hair',
image: 'https://picsum.photos/400/300?random=3'
},
{
id: 's4',
name: 'Full Body Scrub',
description: 'Exfoliating treatment to remove dead skin cells and improve circulation.',
durationMin: 60,
price: 110,
deposit: 25,
category: 'Body',
image: 'https://picsum.photos/400/300?random=4'
}
];
// Mock time slots
// TODO: In production, these should be fetched from the availability API
export const TIME_SLOTS: TimeSlot[] = [
{ id: 't1', time: '09:00 AM', available: true },
{ id: 't2', time: '10:00 AM', available: true },
{ id: 't3', time: '11:00 AM', available: false },
{ id: 't4', time: '01:00 PM', available: true },
{ id: 't5', time: '02:00 PM', available: true },
{ id: 't6', time: '03:00 PM', available: true },
{ id: 't7', time: '04:00 PM', available: false },
{ id: 't8', time: '05:00 PM', available: true },
];
export const APP_NAME = "SmoothSchedule";

View File

@@ -0,0 +1,36 @@
export interface Service {
id: string;
name: string;
description: string;
durationMin: number;
price: number;
deposit: number;
image: string;
category: string;
}
export interface User {
id: string;
name: string;
email: string;
}
export interface TimeSlot {
id: string;
time: string; // "09:00 AM"
available: boolean;
}
export interface BookingState {
step: number;
service: Service | null;
date: Date | null;
timeSlot: string | null;
user: User | null;
paymentMethod: string | null;
}
export interface ChatMessage {
role: 'user' | 'model';
text: string;
}

View File

@@ -1,15 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Clock,
MapPin,
User,
Calendar,
import {
Clock,
DollarSign,
Image as ImageIcon,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { Service, Business } from '../../types';
import Card from '../ui/Card';
import Badge from '../ui/Badge';
interface CustomerPreviewProps {
@@ -33,23 +31,22 @@ export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
name: previewData?.name || service?.name || 'New Service',
description: previewData?.description || service?.description || 'Service description will appear here...',
durationMinutes: previewData?.durationMinutes ?? service?.durationMinutes ?? 30,
photos: previewData?.photos ?? service?.photos ?? [],
};
// Get the first photo for the cover image
const coverPhoto = data.photos && data.photos.length > 0 ? data.photos[0] : null;
const formatPrice = (price: number | string) => {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(numPrice);
};
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
return `${mins}m`;
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-2">
@@ -59,82 +56,86 @@ export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
<Badge variant="info" size="sm">Live Preview</Badge>
</div>
{/* Booking Page Card Simulation */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden transform transition-all hover:scale-[1.02]">
{/* Cover Image Placeholder */}
<div
className="h-32 w-full bg-cover bg-center relative"
style={{
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-400, ${business.secondaryColor}))`,
opacity: 0.9
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-white/20 font-bold text-4xl select-none">
{data.name.charAt(0)}
</span>
</div>
</div>
<div className="p-6">
<div className="flex justify-between items-start gap-4 mb-4">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white leading-tight mb-1">
{data.name}
</h2>
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Clock size={14} />
<span>{formatDuration(data.durationMinutes)}</span>
<span></span>
<span>{data.category?.name || 'General'}</span>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-brand-600 dark:text-brand-400">
{data.variable_pricing ? (
'Variable'
) : (
formatPrice(data.price)
)}
</div>
{data.deposit_amount && data.deposit_amount > 0 && (
<div className="text-xs text-gray-500">
{formatPrice(data.deposit_amount)} deposit
</div>
)}
</div>
</div>
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed mb-6">
{data.description}
</p>
<div className="space-y-3 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<div className="p-1.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400">
<CheckCircle2 size={14} />
</div>
<span>Online booking available</span>
</div>
{(data.resource_ids?.length || 0) > 0 && !data.all_resources && (
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<div className="p-1.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
<User size={14} />
</div>
<span>Specific staff only</span>
{/* Lumina-style Horizontal Card */}
<div className="relative overflow-hidden rounded-xl border-2 border-brand-600 bg-brand-50/50 dark:bg-brand-900/20 ring-2 ring-brand-600 ring-offset-2 dark:ring-offset-gray-900 transition-all duration-200">
<div className="flex h-full min-h-[180px]">
{/* Image Section - 1/3 width */}
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
{coverPhoto ? (
<img
src={coverPhoto}
alt={data.name}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor || '#2563eb'}), var(--color-brand-400, ${business.secondaryColor || '#0ea5e9'}))`
}}
>
<ImageIcon className="w-12 h-12 text-white/30" />
</div>
)}
</div>
<div className="mt-6">
<button className="w-full py-2.5 px-4 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-xl transition-colors shadow-sm shadow-brand-200 dark:shadow-none">
Book Now
</button>
{/* Content Section - 2/3 width */}
<div className="w-2/3 p-5 flex flex-col justify-between">
<div>
{/* Category Badge */}
<div className="flex justify-between items-start">
<span className="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/50 px-2.5 py-0.5 text-xs font-medium text-brand-800 dark:text-brand-300">
{data.category?.name || 'General'}
</span>
{data.variable_pricing && (
<span className="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:text-purple-300">
Variable
</span>
)}
</div>
{/* Title */}
<h3 className="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
{data.name}
</h3>
{/* Description */}
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{data.description}
</p>
</div>
{/* Bottom Info */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center text-gray-600 dark:text-gray-300">
<Clock className="w-4 h-4 mr-1.5" />
{data.durationMinutes} mins
</div>
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
{data.variable_pricing ? (
<span className="text-purple-600 dark:text-purple-400">Price varies</span>
) : (
<>
<DollarSign className="w-4 h-4" />
{data.price}
</>
)}
</div>
</div>
{/* Deposit Info */}
{((data.deposit_amount && data.deposit_amount > 0) || (data.variable_pricing && data.deposit_amount)) && (
<div className="mt-2 text-xs text-brand-600 dark:text-brand-400 font-medium">
Deposit required: {formatPrice(data.deposit_amount || 0)}
</div>
)}
</div>
</div>
</div>
</div>
{/* Info Note */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 flex gap-3 items-start">
<AlertCircle size={20} className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<p className="text-sm text-blue-800 dark:text-blue-300">

View File

@@ -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<TimeBlockCalendarOverlayProps> = ({
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<TimeBlockCalendarOverlayProps> = ({
<>
{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 (
<div
@@ -224,14 +209,12 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
onMouseLeave={handleMouseLeave}
onClick={() => onDayClick?.(days[overlay.dayIndex])}
>
{/* Block level indicator */}
<div className={`absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide ${
isBusinessLevel
? 'bg-red-600'
: 'bg-purple-600'
}`}>
{isBusinessLevel ? 'B' : 'R'}
</div>
{/* Only show badge for resource-level blocks */}
{!isBusinessLevel && (
<div className="absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide bg-purple-600">
R
</div>
)}
</div>
);
})}

View File

@@ -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<CurrencyInputProps> = ({
value,
@@ -33,128 +33,110 @@ const CurrencyInput: React.FC<CurrencyInputProps> = ({
max,
}) => {
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<CurrencyInputProps> = ({
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}

View File

@@ -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<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
icon?: LucideIcon;
iconPosition?: 'left' | 'right';
loading?: boolean;
children: React.ReactNode;
}
export const LuminaButton: React.FC<LuminaButtonProps> = ({
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 (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${disabledClasses} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading ? (
<span className="animate-pulse">Processing...</span>
) : (
<>
{Icon && iconPosition === 'left' && <Icon className="w-4 h-4 mr-2" />}
{children}
{Icon && iconPosition === 'right' && <Icon className="w-4 h-4 ml-2" />}
</>
)}
</button>
);
};
// ============================================================================
// Input Components
// ============================================================================
interface LuminaInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
icon?: LucideIcon;
}
export const LuminaInput: React.FC<LuminaInputProps> = ({
label,
error,
hint,
icon: Icon,
className = '',
...props
}) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative">
{Icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon className="h-5 w-5 text-gray-400" />
</div>
)}
<input
className={`block w-full ${Icon ? 'pl-10' : 'pl-3'} pr-3 py-2.5 border ${
error ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'
} rounded-lg transition-colors ${className}`}
{...props}
/>
</div>
{error && <p className="text-sm text-red-600 mt-1">{error}</p>}
{hint && !error && <p className="text-sm text-gray-500 mt-1">{hint}</p>}
</div>
);
};
// ============================================================================
// Card Components
// ============================================================================
interface LuminaCardProps {
children: React.ReactNode;
className?: string;
padding?: 'none' | 'sm' | 'md' | 'lg';
hover?: boolean;
}
export const LuminaCard: React.FC<LuminaCardProps> = ({
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 (
<div className={`bg-white rounded-2xl shadow-sm border border-gray-100 ${paddingClasses[padding]} ${hoverClasses} ${className}`}>
{children}
</div>
);
};
// ============================================================================
// Badge Components
// ============================================================================
interface LuminaBadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
size?: 'sm' | 'md';
}
export const LuminaBadge: React.FC<LuminaBadgeProps> = ({
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 (
<span className={`inline-flex items-center font-medium rounded-full ${variantClasses[variant]} ${sizeClasses[size]}`}>
{children}
</span>
);
};
// ============================================================================
// Section Container
// ============================================================================
interface LuminaSectionProps {
children: React.ReactNode;
title?: string;
subtitle?: string;
className?: string;
}
export const LuminaSection: React.FC<LuminaSectionProps> = ({
children,
title,
subtitle,
className = '',
}) => {
return (
<section className={`py-16 px-4 sm:px-6 lg:px-8 ${className}`}>
<div className="max-w-7xl mx-auto">
{(title || subtitle) && (
<div className="text-center mb-12">
{title && <h2 className="text-3xl font-bold text-gray-900 mb-3">{title}</h2>}
{subtitle && <p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>}
</div>
)}
{children}
</div>
</section>
);
};
// ============================================================================
// Icon Box Component
// ============================================================================
interface LuminaIconBoxProps {
icon: LucideIcon;
color?: 'indigo' | 'green' | 'amber' | 'red' | 'blue';
size?: 'sm' | 'md' | 'lg';
}
export const LuminaIconBox: React.FC<LuminaIconBoxProps> = ({
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 (
<div className={`${sizeClasses[size]} ${colorClasses[color]} rounded-xl flex items-center justify-center`}>
<Icon className={iconSizeClasses[size]} />
</div>
);
};
// ============================================================================
// Feature Card Component
// ============================================================================
interface LuminaFeatureCardProps {
icon: LucideIcon;
title: string;
description: string;
onClick?: () => void;
}
export const LuminaFeatureCard: React.FC<LuminaFeatureCardProps> = ({
icon,
title,
description,
onClick,
}) => {
return (
<LuminaCard
hover={!!onClick}
className={onClick ? 'cursor-pointer' : ''}
onClick={onClick}
>
<div className="flex flex-col items-center text-center">
<LuminaIconBox icon={icon} size="lg" />
<h3 className="mt-4 text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-2 text-gray-600">{description}</p>
</div>
</LuminaCard>
);
};
// ============================================================================
// Loading Spinner
// ============================================================================
interface LuminaSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export const LuminaSpinner: React.FC<LuminaSpinnerProps> = ({
size = 'md',
className = '',
}) => {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<div className={`animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600 ${sizeClasses[size]} ${className}`} />
);
};

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,

View File

@@ -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')}
/>
<SettingsSidebarItem
to="/settings/business-hours"
icon={Clock}
label={t('settings.businessHours.title', 'Business Hours')}
description={t('settings.businessHours.description', 'Operating hours')}
/>
</SettingsSidebarSection>
{/* Branding Section */}

View File

@@ -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<BookingState> => {
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<BookingState>({
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 }) => (
<div className={`flex ${showBack && showContinue ? 'justify-between' : showBack ? 'justify-start' : 'justify-end'} pt-6 mt-6 border-t border-gray-200 dark:border-gray-700`}>
{showBack && (
<button
onClick={prevStep}
className="flex items-center px-5 py-2.5 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</button>
)}
{showContinue && (
<button
onClick={onContinue}
disabled={continueDisabled}
className="flex items-center px-6 py-2.5 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg font-medium hover:bg-indigo-700 dark:hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{continueLabel}
<ArrowRight className="w-4 h-4 ml-2" />
</button>
)}
</div>
);
const renderStep = () => {
switch (bookingState.step) {
case 1:
return (
<div>
<ServiceSelection selectedService={bookingState.service} onSelect={handleServiceSelect} />
<StepNavigation showBack={true} showContinue={false} />
</div>
);
case 2:
return (
<div>
<DateTimeSelection
serviceId={bookingState.service?.id}
selectedDate={bookingState.date}
selectedTimeSlot={bookingState.timeSlot}
onDateChange={handleDateChange}
onTimeChange={handleTimeChange}
/>
<StepNavigation
showBack={true}
showContinue={true}
continueDisabled={!bookingState.date || !bookingState.timeSlot}
onContinue={nextStep}
/>
</div>
);
case 3:
return (
<div>
<AuthSection onLogin={handleLogin} />
<StepNavigation showBack={true} showContinue={false} />
</div>
);
case 4:
return bookingState.service ? (
<div>
<PaymentSection service={bookingState.service} onPaymentComplete={handlePaymentComplete} />
<StepNavigation showBack={true} showContinue={false} />
</div>
) : null;
case 5:
return <Confirmation booking={bookingState} />;
default:
return null;
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={prevStep}
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="text-sm text-gray-600 dark:text-gray-300">
{bookingState.step < 5 ? 'Book an Appointment' : 'Booking Complete'}
</div>
</div>
{bookingState.user && bookingState.step < 5 && (
<div className="text-sm text-gray-600 dark:text-gray-300">
Hi, <span className="font-medium text-gray-900 dark:text-white">{bookingState.user.name}</span>
</div>
)}
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
{/* Progress Stepper */}
{bookingState.step < 5 && (
<div className="mb-12">
<Steps currentStep={bookingState.step} />
</div>
)}
{/* Booking Summary (steps 2-4) */}
{bookingState.step > 1 && bookingState.step < 5 && (
<div className="mb-8 p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm flex flex-wrap items-center gap-4 text-sm text-gray-600 dark:text-gray-300">
{bookingState.service && (
<div className="flex items-center">
<span className="font-medium text-gray-900 dark:text-white mr-2">Service:</span>
{bookingState.service.name} (${(bookingState.service.price_cents / 100).toFixed(2)})
</div>
)}
{bookingState.date && bookingState.timeSlot && (
<>
<div className="w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
<div className="flex items-center">
<span className="font-medium text-gray-900 dark:text-white mr-2">Time:</span>
{bookingState.date.toLocaleDateString()} at {bookingState.timeSlot}
</div>
</>
)}
</div>
)}
{/* Main Content */}
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
{renderStep()}
</div>
</main>
</div>
);
};
export default BookingFlow;

View File

@@ -1356,8 +1356,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ 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<OwnerSchedulerProps> = ({ 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<OwnerSchedulerProps> = ({ user, business }) => {
}`}>
{date.getDate()}
</div>
<div className="flex gap-1">
{hasBusinessHard && (
<span className="text-[10px] px-1.5 py-0.5 bg-red-500 text-white rounded font-semibold" title={businessBlocks.find(b => b.block_type === 'HARD')?.title}>
B
</span>
)}
{!hasBusinessHard && hasBusinessSoft && (
<span className="text-[10px] px-1.5 py-0.5 bg-yellow-500 text-white rounded font-semibold" title={businessBlocks.find(b => b.block_type === 'SOFT')?.title}>
B
</span>
)}
</div>
</div>
<div className="space-y-1">
{displayedAppointments.map(apt => {
@@ -1712,6 +1699,61 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
);
})}
{/* Blocked dates overlay for this resource */}
{blockedDates
.filter(block => {
// Filter for this day and this resource (or business-level blocks)
const [year, month, day] = block.date.split('-').map(Number);
const blockDate = new Date(year, month - 1, day);
blockDate.setHours(0, 0, 0, 0);
const targetDate = new Date(monthDropTarget!.date);
targetDate.setHours(0, 0, 0, 0);
const isCorrectDay = blockDate.getTime() === targetDate.getTime();
const isCorrectResource = block.resource_id === null || block.resource_id === layout.resource.id;
return isCorrectDay && isCorrectResource;
})
.map((block, blockIndex) => {
let left: number;
let width: number;
if (block.all_day) {
left = 0;
width = overlayTimelineWidth;
} else if (block.start_time && block.end_time) {
const [startHours, startMins] = block.start_time.split(':').map(Number);
const [endHours, endMins] = block.end_time.split(':').map(Number);
const startMinutes = (startHours - START_HOUR) * 60 + startMins;
const endMinutes = (endHours - START_HOUR) * 60 + endMins;
left = startMinutes * OVERLAY_PIXELS_PER_MINUTE;
width = (endMinutes - startMinutes) * OVERLAY_PIXELS_PER_MINUTE;
} else {
left = 0;
width = overlayTimelineWidth;
}
const isBusinessLevel = block.resource_id === null;
return (
<div
key={`block-${block.time_block_id}-${blockIndex}`}
className="absolute top-0 bottom-0 pointer-events-none"
style={{
left,
width,
background: isBusinessLevel
? 'rgba(107, 114, 128, 0.15)'
: block.block_type === 'HARD'
? 'repeating-linear-gradient(-45deg, rgba(147, 51, 234, 0.2), rgba(147, 51, 234, 0.2) 3px, rgba(147, 51, 234, 0.35) 3px, rgba(147, 51, 234, 0.35) 6px)'
: 'rgba(6, 182, 212, 0.15)',
zIndex: 5,
}}
title={block.title}
/>
);
})}
{/* Appointments (including preview) */}
{layout.appointments.map(apt => {
const left = apt.startMinutes * OVERLAY_PIXELS_PER_MINUTE;

View File

@@ -2,52 +2,201 @@ import React, { useState, useEffect } from 'react';
import { Puck } from "@measured/puck";
import "@measured/puck/puck.css";
import { config } from "../puckConfig";
import { usePages, useUpdatePage } from "../hooks/useSites";
import { Loader2 } from "lucide-react";
import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites";
import { Loader2, Plus, Trash2, FileText } from "lucide-react";
import toast from 'react-hot-toast';
import { useAuth } from '../hooks/useAuth';
export const PageEditor: React.FC = () => {
const { data: pages, isLoading } = usePages();
const { user } = useAuth();
const updatePage = useUpdatePage();
const createPage = useCreatePage();
const deletePage = useDeletePage();
const [data, setData] = useState<any>(null);
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
const [showNewPageModal, setShowNewPageModal] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const homePage = pages?.find((p: any) => p.is_home) || pages?.[0];
const currentPage = pages?.find((p: any) => p.id === currentPageId) || pages?.find((p: any) => p.is_home) || pages?.[0];
useEffect(() => {
if (homePage?.puck_data) {
if (currentPage?.puck_data) {
// Ensure data structure is valid for Puck
const puckData = homePage.puck_data;
const puckData = currentPage.puck_data;
if (!puckData.content) puckData.content = [];
if (!puckData.root) puckData.root = {};
setData(puckData);
} else if (homePage) {
} else if (currentPage) {
setData({ content: [], root: {} });
}
}, [homePage]);
}, [currentPage]);
const handlePublish = async (newData: any) => {
if (!homePage) return;
if (!currentPage) return;
// Check if user has permission to customize
const hasPermission = (user as any)?.tenant?.can_customize_booking_page || false;
if (!hasPermission) {
toast.error("Your plan does not include site customization. Please upgrade to edit pages.");
return;
}
try {
await updatePage.mutateAsync({ id: homePage.id, data: { puck_data: newData } });
await updatePage.mutateAsync({ id: currentPage.id, data: { puck_data: newData } });
toast.success("Page published successfully!");
} catch (error) {
toast.error("Failed to publish page.");
} catch (error: any) {
const errorMsg = error?.response?.data?.error || "Failed to publish page.";
toast.error(errorMsg);
console.error(error);
}
};
const handleCreatePage = async () => {
if (!newPageTitle.trim()) {
toast.error("Page title is required");
return;
}
try {
const newPage = await createPage.mutateAsync({
title: newPageTitle,
});
toast.success(`Page "${newPageTitle}" created!`);
setNewPageTitle('');
setShowNewPageModal(false);
setCurrentPageId(newPage.id);
} catch (error: any) {
const errorMsg = error?.response?.data?.error || "Failed to create page";
toast.error(errorMsg);
}
};
const handleDeletePage = async (pageId: string) => {
if (!confirm("Are you sure you want to delete this page?")) return;
try {
await deletePage.mutateAsync(pageId);
toast.success("Page deleted!");
setCurrentPageId(null);
} catch (error) {
toast.error("Failed to delete page");
}
};
if (isLoading) {
return <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>;
}
if (!homePage) {
if (!currentPage) {
return <div>No page found. Please contact support.</div>;
}
if (!data) return null;
const maxPages = (user as any)?.tenant?.max_pages || 1;
const pageCount = pages?.length || 0;
const canCustomize = (user as any)?.tenant?.can_customize_booking_page || false;
const canCreateMore = canCustomize && (maxPages === -1 || pageCount < maxPages);
return (
<div className="h-screen flex flex-col">
{/* Permission Notice for Free Tier */}
{!canCustomize && (
<div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-3">
<div className="flex items-center gap-2 text-amber-800 dark:text-amber-200 text-sm">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span>
<strong>Read-Only Mode:</strong> Your current plan does not include site customization.
<a href="#" className="underline ml-1 hover:text-amber-900 dark:hover:text-amber-100">Upgrade to a paid plan</a> to edit your pages.
</span>
</div>
</div>
)}
{/* Page Management Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<FileText size={20} className="text-indigo-600" />
<select
value={currentPageId || currentPage.id}
onChange={(e) => setCurrentPageId(e.target.value)}
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-indigo-500"
>
{pages?.map((page: any) => (
<option key={page.id} value={page.id}>
{page.title} {page.is_home ? '(Home)' : ''}
</option>
))}
</select>
<button
onClick={() => setShowNewPageModal(true)}
disabled={!canCreateMore}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPages})`}
>
<Plus size={16} />
New Page
</button>
{currentPage && !currentPage.is_home && (
<button
onClick={() => handleDeletePage(currentPage.id)}
disabled={!canCustomize}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
title={canCustomize ? "Delete page" : "Upgrade to delete pages"}
>
<Trash2 size={16} />
Delete
</button>
)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pageCount} / {maxPages === -1 ? '∞' : maxPages} pages
</div>
</div>
{/* New Page Modal */}
{showNewPageModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Create New Page
</h3>
<input
type="text"
value={newPageTitle}
onChange={(e) => setNewPageTitle(e.target.value)}
placeholder="Page Title"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
onKeyDown={(e) => e.key === 'Enter' && handleCreatePage()}
autoFocus
/>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowNewPageModal(false);
setNewPageTitle('');
}}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={handleCreatePage}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Create
</button>
</div>
</div>
</div>
)}
<Puck
config={config}
data={data}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
is_active: true,
max_users: 5,
max_resources: 10,
max_pages: 1,
contact_email: '',
phone: '',
can_manage_oauth_credentials: false,
@@ -37,6 +38,7 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
is_active: true,
max_users: 5,
max_resources: 10,
max_pages: 1,
contact_email: '',
phone: '',
can_manage_oauth_credentials: false,
@@ -91,6 +93,7 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
is_active: createForm.is_active,
max_users: createForm.max_users,
max_resources: createForm.max_resources,
max_pages: createForm.max_pages,
can_manage_oauth_credentials: createForm.can_manage_oauth_credentials,
};
@@ -258,7 +261,7 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
</div>
{/* Limits */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Users
@@ -283,6 +286,18 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Pages
</label>
<input
type="number"
min="1"
value={createForm.max_pages}
onChange={(e) => setCreateForm({ ...createForm, max_pages: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
</div>

View File

@@ -9,47 +9,57 @@ import FeaturesPermissionsEditor, { PERMISSION_DEFINITIONS, getPermissionKey } f
const TIER_DEFAULTS: Record<string, {
max_users: number;
max_resources: number;
max_pages: number;
can_manage_oauth_credentials: boolean;
can_accept_payments: boolean;
can_use_custom_domain: boolean;
can_white_label: boolean;
can_api_access: boolean;
can_customize_booking_page: boolean;
}> = {
FREE: {
max_users: 2,
max_resources: 5,
max_pages: 1,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
can_customize_booking_page: false,
},
STARTER: {
max_users: 5,
max_resources: 15,
max_pages: 3,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
can_customize_booking_page: true,
},
PROFESSIONAL: {
max_users: 15,
max_resources: 50,
max_pages: 10,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: false,
can_api_access: true,
can_customize_booking_page: true,
},
ENTERPRISE: {
max_users: -1, // unlimited
max_resources: -1, // unlimited
max_pages: -1, // unlimited
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
can_customize_booking_page: true,
},
};
@@ -70,6 +80,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
// Limits
max_users: 5,
max_resources: 10,
max_pages: 1,
// Platform Permissions (flat, matching backend model)
can_manage_oauth_credentials: false,
can_accept_payments: false,
@@ -123,6 +134,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
// Limits
max_users: plan.limits?.max_users ?? staticDefaults.max_users,
max_resources: plan.limits?.max_resources ?? staticDefaults.max_resources,
max_pages: plan.limits?.max_pages ?? staticDefaults.max_pages,
// Platform Permissions
can_manage_oauth_credentials: plan.permissions?.can_manage_oauth_credentials ?? staticDefaults.can_manage_oauth_credentials,
can_accept_payments: plan.permissions?.can_accept_payments ?? staticDefaults.can_accept_payments,
@@ -201,6 +213,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
// Limits
max_users: business.max_users || 5,
max_resources: business.max_resources || 10,
max_pages: business.max_pages || 1,
// Platform Permissions (flat, matching backend)
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
can_accept_payments: business.can_accept_payments || false,
@@ -347,7 +360,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
<p className="text-xs text-gray-500 dark:text-gray-400">
Use -1 for unlimited. These limits control what this business can create.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Users
@@ -372,9 +385,42 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Pages
</label>
<input
type="number"
min="-1"
value={editForm.max_pages}
onChange={(e) => setEditForm({ ...editForm, max_pages: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
</div>
{/* Site Builder Access */}
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Site Builder
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Access the public-facing website builder for this business. Current limit: {editForm.max_pages === -1 ? 'unlimited' : editForm.max_pages} page{editForm.max_pages !== 1 ? 's' : ''}.
</p>
<a
href={`http://${business.subdomain}.lvh.me:5173/site-editor`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400 rounded-lg hover:bg-indigo-100 dark:hover:bg-indigo-900/30 font-medium text-sm transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open Site Builder
</a>
</div>
{/* Features & Permissions - Using unified FeaturesPermissionsEditor */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<FeaturesPermissionsEditor

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;

View File

@@ -1,11 +1,18 @@
import React from "react";
import type { Config } from "@measured/puck";
import BookingWidget from "./components/booking/BookingWidget";
import { ArrowRight } from "lucide-react";
type Props = {
Hero: { title: string; subtitle: string; align: "left" | "center" | "right"; backgroundColor: string; textColor: string };
TextSection: { heading: string; body: string; backgroundColor: string };
Booking: { headline: string; subheading: string; accentColor: string; buttonLabel: string };
Hero: {
title: string;
subtitle: string;
align: "left" | "center" | "right";
ctaText?: string;
ctaLink?: string;
};
TextSection: { heading: string; body: string };
Booking: { headline: string; subheading: string };
};
export const config: Config<Props> = {
@@ -22,65 +29,91 @@ export const config: Config<Props> = {
{ label: "Right", value: "right" },
],
},
backgroundColor: { type: "text" }, // Puck doesn't have color picker by default? Or use "custom"?
textColor: { type: "text" },
ctaText: { type: "text", label: "Button Text" },
ctaLink: { type: "text", label: "Button Link" },
},
defaultProps: {
title: "Welcome to our site",
subtitle: "We provide great services",
align: "center",
backgroundColor: "#ffffff",
textColor: "#000000",
ctaText: "Book Now",
ctaLink: "/book",
},
render: ({ title, subtitle, align, backgroundColor, textColor }) => (
<div style={{ backgroundColor, color: textColor, padding: "4rem 2rem", textAlign: align }}>
<h1 style={{ fontSize: "3rem", fontWeight: "bold", marginBottom: "1rem" }}>{title}</h1>
<p style={{ fontSize: "1.5rem" }}>{subtitle}</p>
</div>
render: ({ title, subtitle, align, ctaText, ctaLink }) => (
<section className="relative bg-gradient-to-br from-gray-50 to-white dark:from-gray-900 dark:to-gray-800 py-20 sm:py-28">
<div className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"></div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className={`relative z-10 ${align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left'}`}>
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white mb-6 tracking-tight">
{title}
</h1>
<p className="text-xl sm:text-2xl text-gray-600 dark:text-gray-300 mb-10 max-w-3xl mx-auto leading-relaxed">
{subtitle}
</p>
{ctaText && ctaLink && (
<a
href={ctaLink}
className="inline-flex items-center px-8 py-4 bg-indigo-600 dark:bg-indigo-500 text-white text-lg font-semibold rounded-xl shadow-lg hover:bg-indigo-700 dark:hover:bg-indigo-600 hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200"
>
{ctaText}
<ArrowRight className="ml-2 w-5 h-5" />
</a>
)}
</div>
</div>
</section>
),
},
TextSection: {
fields: {
heading: { type: "text" },
body: { type: "textarea" },
backgroundColor: { type: "text" },
},
defaultProps: {
heading: "About Us",
body: "Enter your text here...",
backgroundColor: "#f9fafb",
},
render: ({ heading, body, backgroundColor }) => (
<div style={{ backgroundColor, padding: "3rem 2rem" }}>
<div style={{ maxWidth: "800px", margin: "0 auto" }}>
<h2 style={{ fontSize: "2rem", marginBottom: "1rem" }}>{heading}</h2>
<div style={{ whiteSpace: "pre-wrap" }}>{body}</div>
render: ({ heading, body }) => (
<section className="py-16 sm:py-20 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-6">
{heading}
</h2>
<div className="text-lg text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
{body}
</div>
</div>
</div>
</section>
),
},
Booking: {
fields: {
headline: { type: "text" },
subheading: { type: "text" },
accentColor: { type: "text" },
buttonLabel: { type: "text" },
},
defaultProps: {
headline: "Book an Appointment",
subheading: "Select a service below",
accentColor: "#2563eb",
buttonLabel: "Book Now",
headline: "Schedule Your Appointment",
subheading: "Choose a service and time that works for you",
},
render: ({ headline, subheading, accentColor, buttonLabel }) => (
<div style={{ padding: "3rem 2rem", textAlign: "center" }}>
<BookingWidget
headline={headline}
subheading={subheading}
accentColor={accentColor}
buttonLabel={buttonLabel}
/>
</div>
render: ({ headline, subheading }) => (
<section className="py-16 sm:py-20 bg-gray-50 dark:bg-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{headline}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
{subheading}
</p>
</div>
<BookingWidget
headline={headline}
subheading={subheading}
accentColor="#4f46e5"
buttonLabel="Book Now"
/>
</div>
</section>
),
},
},

View File

@@ -77,6 +77,9 @@ export interface Business {
stripeConnectAccountId?: string;
websitePages?: Record<string, { name: string; content: PageComponent[] }>;
customerDashboardContent?: PageComponent[];
// Booking page customization
serviceSelectionHeading?: string; // Custom heading for service selection (default: "Choose your experience")
serviceSelectionSubheading?: string; // Custom subheading (default: "Select a service to begin your booking.")
trialStart?: string;
trialEnd?: string;
isTrialActive?: boolean;
@@ -215,19 +218,45 @@ export interface Service {
id: string;
name: string;
durationMinutes: number;
duration?: number; // Duration in minutes (backend field name)
price: number;
price_cents?: number; // Price in cents
description: string;
displayOrder: number;
display_order?: number;
photos?: string[];
is_active?: boolean;
created_at?: string; // Used for quota overage calculation (oldest archived first)
updated_at?: string;
is_archived_by_quota?: boolean; // True if archived due to quota overage
// Pricing fields
variable_pricing?: boolean; // If true, final price is determined after service completion
deposit_amount?: number | null; // Fixed deposit amount
deposit_amount?: number | null; // Fixed deposit amount in dollars
deposit_amount_cents?: number | null; // Fixed deposit amount in cents
deposit_percent?: number | null; // Deposit as percentage (only for fixed pricing)
requires_deposit?: boolean; // True if deposit configured (computed)
requires_saved_payment_method?: boolean; // True if deposit > 0 or variable pricing (computed)
deposit_display?: string | null; // Human-readable deposit description
// Resource assignment
all_resources?: boolean;
resource_ids?: string[];
resource_names?: string[];
// Buffer time (frontend-only for now)
prep_time?: number;
takedown_time?: number;
// Notification settings (frontend-only for now)
reminder_enabled?: boolean;
reminder_hours_before?: number;
reminder_email?: boolean;
reminder_sms?: boolean;
thank_you_email_enabled?: boolean;
// Category (future feature)
category?: string | null;
}
export interface Metric {
@@ -548,6 +577,7 @@ export interface ContractPublicView {
// --- Time Blocking Types ---
export type BlockType = 'HARD' | 'SOFT';
export type BlockPurpose = 'CLOSURE' | 'UNAVAILABLE' | 'BUSINESS_HOURS' | 'OTHER';
export type RecurrenceType = 'NONE' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'HOLIDAY';
export type TimeBlockLevel = 'business' | 'resource';
@@ -583,6 +613,7 @@ export interface TimeBlock {
resource_name?: string;
level: TimeBlockLevel;
block_type: BlockType;
purpose: BlockPurpose;
recurrence_type: RecurrenceType;
start_date?: string; // ISO date string (for NONE type)
end_date?: string; // ISO date string (for NONE type)
@@ -612,6 +643,7 @@ export interface TimeBlockListItem {
resource_name?: string;
level: TimeBlockLevel;
block_type: BlockType;
purpose: BlockPurpose;
recurrence_type: RecurrenceType;
start_date?: string;
end_date?: string;
@@ -635,6 +667,7 @@ export interface TimeBlockListItem {
export interface BlockedDate {
date: string; // ISO date string
block_type: BlockType;
purpose: BlockPurpose;
title: string;
resource_id: string | null;
all_day: boolean;