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:
@@ -112,6 +112,7 @@ const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates'));
|
|||||||
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
|
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
|
||||||
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
|
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
|
||||||
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
|
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
|
||||||
|
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
|
||||||
|
|
||||||
// Settings pages
|
// Settings pages
|
||||||
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
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 CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
||||||
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
||||||
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
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
|
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||||
|
|
||||||
@@ -349,6 +351,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PublicPage />} />
|
<Route path="/" element={<PublicPage />} />
|
||||||
|
<Route path="/book" element={<BookingFlow />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||||
@@ -889,6 +892,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="branding" element={<BrandingSettings />} />
|
<Route path="branding" element={<BrandingSettings />} />
|
||||||
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
||||||
<Route path="booking" element={<BookingSettings />} />
|
<Route path="booking" element={<BookingSettings />} />
|
||||||
|
<Route path="business-hours" element={<BusinessHoursSettings />} />
|
||||||
<Route path="email-templates" element={<EmailTemplates />} />
|
<Route path="email-templates" element={<EmailTemplates />} />
|
||||||
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
||||||
<Route path="api" element={<ApiSettings />} />
|
<Route path="api" element={<ApiSettings />} />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface PlatformBusiness {
|
|||||||
owner: PlatformBusinessOwner | null;
|
owner: PlatformBusinessOwner | null;
|
||||||
max_users: number;
|
max_users: number;
|
||||||
max_resources: number;
|
max_resources: number;
|
||||||
|
max_pages: number;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
// Platform permissions
|
// Platform permissions
|
||||||
@@ -51,6 +52,7 @@ export interface PlatformBusiness {
|
|||||||
can_use_webhooks?: boolean;
|
can_use_webhooks?: boolean;
|
||||||
can_use_calendar_sync?: boolean;
|
can_use_calendar_sync?: boolean;
|
||||||
can_use_contracts?: boolean;
|
can_use_contracts?: boolean;
|
||||||
|
can_customize_booking_page?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformBusinessUpdate {
|
export interface PlatformBusinessUpdate {
|
||||||
@@ -59,6 +61,7 @@ export interface PlatformBusinessUpdate {
|
|||||||
subscription_tier?: string;
|
subscription_tier?: string;
|
||||||
max_users?: number;
|
max_users?: number;
|
||||||
max_resources?: number;
|
max_resources?: number;
|
||||||
|
max_pages?: number;
|
||||||
// Platform permissions
|
// Platform permissions
|
||||||
can_manage_oauth_credentials?: boolean;
|
can_manage_oauth_credentials?: boolean;
|
||||||
can_accept_payments?: boolean;
|
can_accept_payments?: boolean;
|
||||||
@@ -83,10 +86,10 @@ export interface PlatformBusinessUpdate {
|
|||||||
can_use_webhooks?: boolean;
|
can_use_webhooks?: boolean;
|
||||||
can_use_calendar_sync?: boolean;
|
can_use_calendar_sync?: boolean;
|
||||||
can_use_contracts?: boolean;
|
can_use_contracts?: boolean;
|
||||||
|
can_customize_booking_page?: boolean;
|
||||||
can_process_refunds?: boolean;
|
can_process_refunds?: boolean;
|
||||||
can_create_packages?: boolean;
|
can_create_packages?: boolean;
|
||||||
can_use_email_templates?: boolean;
|
can_use_email_templates?: boolean;
|
||||||
can_customize_booking_page?: boolean;
|
|
||||||
advanced_reporting?: boolean;
|
advanced_reporting?: boolean;
|
||||||
priority_support?: boolean;
|
priority_support?: boolean;
|
||||||
dedicated_support?: boolean;
|
dedicated_support?: boolean;
|
||||||
@@ -100,6 +103,7 @@ export interface PlatformBusinessCreate {
|
|||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
max_users?: number;
|
max_users?: number;
|
||||||
max_resources?: number;
|
max_resources?: number;
|
||||||
|
max_pages?: number;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
can_manage_oauth_credentials?: boolean;
|
can_manage_oauth_credentials?: boolean;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Plug,
|
Plug,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
CalendarOff,
|
CalendarOff,
|
||||||
|
LayoutTemplate,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Business, User } from '../types';
|
import { Business, User } from '../types';
|
||||||
import { useLogout } from '../hooks/useAuth';
|
import { useLogout } from '../hooks/useAuth';
|
||||||
@@ -119,6 +120,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
icon={CalendarDays}
|
icon={CalendarDays}
|
||||||
label={t('nav.scheduler')}
|
label={t('nav.scheduler')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
badgeElement={<UnfinishedBadge />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isStaff && (
|
{!isStaff && (
|
||||||
@@ -152,6 +154,13 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
{/* Manage Section - Staff+ */}
|
{/* Manage Section - Staff+ */}
|
||||||
{canViewManagementPages && (
|
{canViewManagementPages && (
|
||||||
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
<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
|
<SidebarItem
|
||||||
to="/customers"
|
to="/customers"
|
||||||
icon={Users}
|
icon={Users}
|
||||||
|
|||||||
361
frontend/src/components/booking/AuthSection.tsx
Normal file
361
frontend/src/components/booking/AuthSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -33,20 +33,24 @@ export const BookingWidget: React.FC<BookingWidgetProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="booking-widget p-6 bg-white rounded-lg shadow-md max-w-md mx-auto text-left">
|
<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" style={{ color: accentColor }}>{headline}</h2>
|
<h2 className="text-2xl font-bold mb-2 text-indigo-600 dark:text-indigo-400">{headline}</h2>
|
||||||
<p className="text-gray-600 mb-6">{subheading}</p>
|
<p className="text-gray-600 dark:text-gray-300 mb-6">{subheading}</p>
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
<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) => (
|
{services?.map((service: any) => (
|
||||||
<div
|
<div
|
||||||
key={service.id}
|
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'}`}
|
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)}
|
onClick={() => setSelectedService(service)}
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold">{service.name}</h3>
|
<h3 className="font-semibold text-gray-900 dark:text-white">{service.name}</h3>
|
||||||
<p className="text-sm text-gray-500">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,8 +58,7 @@ export const BookingWidget: React.FC<BookingWidgetProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleBook}
|
onClick={handleBook}
|
||||||
disabled={!selectedService}
|
disabled={!selectedService}
|
||||||
className="w-full py-2 px-4 rounded text-white font-medium disabled:opacity-50 transition-opacity"
|
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"
|
||||||
style={{ backgroundColor: accentColor }}
|
|
||||||
>
|
>
|
||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
113
frontend/src/components/booking/Confirmation.tsx
Normal file
113
frontend/src/components/booking/Confirmation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
276
frontend/src/components/booking/DateTimeSelection.tsx
Normal file
276
frontend/src/components/booking/DateTimeSelection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
134
frontend/src/components/booking/GeminiChat.tsx
Normal file
134
frontend/src/components/booking/GeminiChat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
frontend/src/components/booking/PaymentSection.tsx
Normal file
159
frontend/src/components/booking/PaymentSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
114
frontend/src/components/booking/ServiceSelection.tsx
Normal file
114
frontend/src/components/booking/ServiceSelection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
frontend/src/components/booking/Steps.tsx
Normal file
61
frontend/src/components/booking/Steps.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
frontend/src/components/booking/constants.ts
Normal file
61
frontend/src/components/booking/constants.ts
Normal 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";
|
||||||
36
frontend/src/components/booking/types.ts
Normal file
36
frontend/src/components/booking/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,14 +2,12 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
MapPin,
|
DollarSign,
|
||||||
User,
|
Image as ImageIcon,
|
||||||
Calendar,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Service, Business } from '../../types';
|
import { Service, Business } from '../../types';
|
||||||
import Card from '../ui/Card';
|
|
||||||
import Badge from '../ui/Badge';
|
import Badge from '../ui/Badge';
|
||||||
|
|
||||||
interface CustomerPreviewProps {
|
interface CustomerPreviewProps {
|
||||||
@@ -33,23 +31,22 @@ export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
|
|||||||
name: previewData?.name || service?.name || 'New Service',
|
name: previewData?.name || service?.name || 'New Service',
|
||||||
description: previewData?.description || service?.description || 'Service description will appear here...',
|
description: previewData?.description || service?.description || 'Service description will appear here...',
|
||||||
durationMinutes: previewData?.durationMinutes ?? service?.durationMinutes ?? 30,
|
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 formatPrice = (price: number | string) => {
|
||||||
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
}).format(numPrice);
|
}).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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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>
|
<Badge variant="info" size="sm">Live Preview</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking Page Card Simulation */}
|
{/* Lumina-style Horizontal Card */}
|
||||||
<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]">
|
<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">
|
||||||
{/* Cover Image Placeholder */}
|
<div className="flex h-full min-h-[180px]">
|
||||||
<div
|
{/* Image Section - 1/3 width */}
|
||||||
className="h-32 w-full bg-cover bg-center relative"
|
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
|
||||||
style={{
|
{coverPhoto ? (
|
||||||
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-400, ${business.secondaryColor}))`,
|
<img
|
||||||
opacity: 0.9
|
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">
|
/>
|
||||||
<span className="text-white/20 font-bold text-4xl select-none">
|
) : (
|
||||||
{data.name.charAt(0)}
|
<div
|
||||||
</span>
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
</div>
|
style={{
|
||||||
</div>
|
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor || '#2563eb'}), var(--color-brand-400, ${business.secondaryColor || '#0ea5e9'}))`
|
||||||
|
}}
|
||||||
<div className="p-6">
|
>
|
||||||
<div className="flex justify-between items-start gap-4 mb-4">
|
<ImageIcon className="w-12 h-12 text-white/30" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
{/* Content Section - 2/3 width */}
|
||||||
<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">
|
<div className="w-2/3 p-5 flex flex-col justify-between">
|
||||||
Book Now
|
<div>
|
||||||
</button>
|
{/* 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Info Note */}
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 flex gap-3 items-start">
|
<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" />
|
<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">
|
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { BlockedDate, BlockType } from '../../types';
|
import { BlockedDate, BlockType, BlockPurpose } from '../../types';
|
||||||
|
|
||||||
interface TimeBlockCalendarOverlayProps {
|
interface TimeBlockCalendarOverlayProps {
|
||||||
blockedDates: BlockedDate[];
|
blockedDates: BlockedDate[];
|
||||||
@@ -126,61 +126,46 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
|||||||
return overlays;
|
return overlays;
|
||||||
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
|
}, [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 = {
|
const baseStyle: React.CSSProperties = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
cursor: 'default',
|
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) {
|
if (isBusinessLevel) {
|
||||||
// Business blocks: Red (hard) / Amber (soft)
|
return {
|
||||||
if (blockType === 'HARD') {
|
...baseStyle,
|
||||||
return {
|
background: 'rgba(107, 114, 128, 0.25)', // Gray-500 at 25% opacity (more visible)
|
||||||
...baseStyle,
|
};
|
||||||
background: `repeating-linear-gradient(
|
}
|
||||||
-45deg,
|
|
||||||
rgba(239, 68, 68, 0.3),
|
// Resource-level blocks: Purple (hard) / Cyan (soft)
|
||||||
rgba(239, 68, 68, 0.3) 5px,
|
if (blockType === 'HARD') {
|
||||||
rgba(239, 68, 68, 0.5) 5px,
|
return {
|
||||||
rgba(239, 68, 68, 0.5) 10px
|
...baseStyle,
|
||||||
)`,
|
background: `repeating-linear-gradient(
|
||||||
borderTop: '2px solid rgba(239, 68, 68, 0.7)',
|
-45deg,
|
||||||
borderBottom: '2px solid rgba(239, 68, 68, 0.7)',
|
rgba(147, 51, 234, 0.25),
|
||||||
};
|
rgba(147, 51, 234, 0.25) 5px,
|
||||||
} else {
|
rgba(147, 51, 234, 0.4) 5px,
|
||||||
return {
|
rgba(147, 51, 234, 0.4) 10px
|
||||||
...baseStyle,
|
)`,
|
||||||
background: 'rgba(251, 191, 36, 0.2)',
|
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
|
||||||
borderTop: '2px dashed rgba(251, 191, 36, 0.8)',
|
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
|
||||||
borderBottom: '2px dashed rgba(251, 191, 36, 0.8)',
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Resource blocks: Purple (hard) / Cyan (soft)
|
return {
|
||||||
if (blockType === 'HARD') {
|
...baseStyle,
|
||||||
return {
|
background: 'rgba(6, 182, 212, 0.15)',
|
||||||
...baseStyle,
|
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||||
background: `repeating-linear-gradient(
|
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||||
-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)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,7 +193,7 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
|||||||
<>
|
<>
|
||||||
{blockOverlays.map((overlay, index) => {
|
{blockOverlays.map((overlay, index) => {
|
||||||
const isBusinessLevel = overlay.block.resource_id === null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -224,14 +209,12 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
onClick={() => onDayClick?.(days[overlay.dayIndex])}
|
onClick={() => onDayClick?.(days[overlay.dayIndex])}
|
||||||
>
|
>
|
||||||
{/* Block level indicator */}
|
{/* Only show badge for resource-level blocks */}
|
||||||
<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 && (
|
||||||
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">
|
||||||
? 'bg-red-600'
|
R
|
||||||
: 'bg-purple-600'
|
</div>
|
||||||
}`}>
|
)}
|
||||||
{isBusinessLevel ? 'B' : 'R'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface CurrencyInputProps {
|
interface CurrencyInputProps {
|
||||||
value: number; // Value in cents (integer)
|
value: number; // Value in cents (integer)
|
||||||
@@ -12,15 +12,15 @@ interface CurrencyInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ATM-style currency input where digits are entered as cents.
|
* Currency input where digits represent cents.
|
||||||
* As more digits are entered, they shift from cents to dollars.
|
* Only accepts integer input (0-9), no decimal points.
|
||||||
* Only accepts integer values (digits 0-9).
|
* Allows normal text selection and editing.
|
||||||
*
|
*
|
||||||
* Example: typing "1234" displays "$12.34"
|
* Examples:
|
||||||
* - Type "1" → $0.01
|
* - Type "5" → $0.05
|
||||||
* - Type "2" → $0.12
|
* - Type "50" → $0.50
|
||||||
* - Type "3" → $1.23
|
* - Type "500" → $5.00
|
||||||
* - Type "4" → $12.34
|
* - Type "1234" → $12.34
|
||||||
*/
|
*/
|
||||||
const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
||||||
value,
|
value,
|
||||||
@@ -33,128 +33,110 @@ const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
|||||||
max,
|
max,
|
||||||
}) => {
|
}) => {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [displayValue, setDisplayValue] = useState('');
|
||||||
|
|
||||||
// Ensure value is always an integer
|
|
||||||
const safeValue = Math.floor(Math.abs(value)) || 0;
|
|
||||||
|
|
||||||
// Format cents as dollars string (e.g., 1234 → "$12.34")
|
// Format cents as dollars string (e.g., 1234 → "$12.34")
|
||||||
const formatCentsAsDollars = (cents: number): string => {
|
const formatCentsAsDollars = (cents: number): string => {
|
||||||
if (cents === 0 && !isFocused) return '';
|
if (cents === 0) return '';
|
||||||
const dollars = cents / 100;
|
const dollars = cents / 100;
|
||||||
return `$${dollars.toFixed(2)}`;
|
return `$${dollars.toFixed(2)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayValue = safeValue > 0 || isFocused ? formatCentsAsDollars(safeValue) : '';
|
// Extract just the digits from a string
|
||||||
|
const extractDigits = (str: string): string => {
|
||||||
// Process a new digit being added
|
return str.replace(/\D/g, '');
|
||||||
const addDigit = (digit: number) => {
|
|
||||||
let newValue = safeValue * 10 + digit;
|
|
||||||
|
|
||||||
// Enforce max if specified
|
|
||||||
if (max !== undefined && newValue > max) {
|
|
||||||
newValue = max;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(newValue);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove the last digit
|
// Sync display value when external value changes
|
||||||
const removeDigit = () => {
|
useEffect(() => {
|
||||||
const newValue = Math.floor(safeValue / 10);
|
setDisplayValue(formatCentsAsDollars(value));
|
||||||
onChange(newValue);
|
}, [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>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
// Allow navigation keys without preventing default
|
// Allow: navigation, selection, delete, backspace, tab, escape, enter
|
||||||
if (
|
const allowedKeys = [
|
||||||
e.key === 'Tab' ||
|
'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
|
||||||
e.key === 'Escape' ||
|
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
|
||||||
e.key === 'Enter' ||
|
'Home', 'End'
|
||||||
e.key === 'ArrowLeft' ||
|
];
|
||||||
e.key === 'ArrowRight' ||
|
|
||||||
e.key === 'Home' ||
|
if (allowedKeys.includes(e.key)) {
|
||||||
e.key === 'End'
|
return; // Let these through
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle backspace/delete
|
// Allow Ctrl/Cmd + A, C, V, X (select all, copy, paste, cut)
|
||||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
if ((e.ctrlKey || e.metaKey) && ['a', 'c', 'v', 'x'].includes(e.key.toLowerCase())) {
|
||||||
e.preventDefault();
|
|
||||||
removeDigit();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow digits 0-9
|
// Only allow digits 0-9
|
||||||
if (/^[0-9]$/.test(e.key)) {
|
if (!/^[0-9]$/.test(e.key)) {
|
||||||
e.preventDefault();
|
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 = () => {
|
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
setIsFocused(true);
|
// Select all text for easy replacement
|
||||||
|
setTimeout(() => {
|
||||||
|
e.target.select();
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
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
|
// Enforce min on blur if specified
|
||||||
if (min !== undefined && safeValue < min && safeValue > 0) {
|
if (min !== undefined && cents < min && cents > 0) {
|
||||||
onChange(min);
|
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>) => {
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const pastedText = e.clipboardData.getData('text');
|
const pastedText = e.clipboardData.getData('text');
|
||||||
const digits = pastedText.replace(/\D/g, '');
|
const digits = extractDigits(pastedText);
|
||||||
|
|
||||||
if (digits) {
|
if (digits) {
|
||||||
let newValue = parseInt(digits, 10);
|
let cents = parseInt(digits, 10);
|
||||||
if (max !== undefined && newValue > max) {
|
|
||||||
newValue = max;
|
|
||||||
}
|
|
||||||
onChange(newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle drop - extract digits only
|
if (max !== undefined && cents > max) {
|
||||||
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
|
cents = max;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
onChange(newValue);
|
|
||||||
|
onChange(cents);
|
||||||
|
setDisplayValue(formatCentsAsDollars(cents));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,15 +145,12 @@ const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBeforeInput={handleBeforeInput}
|
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onDrop={handleDrop}
|
|
||||||
onChange={() => {}} // Controlled via onKeyDown/onBeforeInput
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
required={required}
|
required={required}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
310
frontend/src/components/ui/lumina.tsx
Normal file
310
frontend/src/components/ui/lumina.tsx
Normal 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}`} />
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,27 @@
|
|||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import api from '../api/client';
|
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 = () => {
|
export const usePublicServices = () => {
|
||||||
return useQuery({
|
return useQuery<PublicService[]>({
|
||||||
queryKey: ['publicServices'],
|
queryKey: ['publicServices'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/public/services/');
|
const response = await api.get('/public/services/');
|
||||||
@@ -12,8 +31,51 @@ export const usePublicServices = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePublicAvailability = (serviceId: string, date: string) => {
|
export const usePublicBusinessInfo = () => {
|
||||||
return useQuery({
|
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],
|
queryKey: ['publicAvailability', serviceId, date],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get(`/public/availability/?service_id=${serviceId}&date=${date}`);
|
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 = () => {
|
export const useCreateBooking = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export const useCurrentBusiness = () => {
|
|||||||
initialSetupComplete: data.initial_setup_complete,
|
initialSetupComplete: data.initial_setup_complete,
|
||||||
websitePages: data.website_pages || {},
|
websitePages: data.website_pages || {},
|
||||||
customerDashboardContent: data.customer_dashboard_content || [],
|
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,
|
paymentsEnabled: data.payments_enabled ?? false,
|
||||||
// Platform-controlled permissions
|
// Platform-controlled permissions
|
||||||
canManageOAuthCredentials: data.can_manage_oauth_credentials || false,
|
canManageOAuthCredentials: data.can_manage_oauth_credentials || false,
|
||||||
@@ -118,6 +121,12 @@ export const useUpdateBusiness = () => {
|
|||||||
if (updates.customerDashboardContent !== undefined) {
|
if (updates.customerDashboardContent !== undefined) {
|
||||||
backendData.customer_dashboard_content = updates.customerDashboardContent;
|
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);
|
const { data } = await apiClient.patch('/business/current/update/', backendData);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -21,16 +21,25 @@ export const useServices = () => {
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
durationMinutes: s.duration || s.duration_minutes,
|
durationMinutes: s.duration || s.duration_minutes,
|
||||||
price: parseFloat(s.price),
|
price: parseFloat(s.price),
|
||||||
|
price_cents: s.price_cents ?? Math.round(parseFloat(s.price) * 100),
|
||||||
description: s.description || '',
|
description: s.description || '',
|
||||||
displayOrder: s.display_order ?? 0,
|
displayOrder: s.display_order ?? 0,
|
||||||
photos: s.photos || [],
|
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
|
// Pricing fields
|
||||||
variable_pricing: s.variable_pricing ?? false,
|
variable_pricing: s.variable_pricing ?? false,
|
||||||
deposit_amount: s.deposit_amount ? parseFloat(s.deposit_amount) : null,
|
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,
|
deposit_percent: s.deposit_percent ? parseFloat(s.deposit_percent) : null,
|
||||||
requires_deposit: s.requires_deposit ?? false,
|
requires_deposit: s.requires_deposit ?? false,
|
||||||
requires_saved_payment_method: s.requires_saved_payment_method ?? false,
|
requires_saved_payment_method: s.requires_saved_payment_method ?? false,
|
||||||
deposit_display: s.deposit_display || null,
|
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
|
retry: false, // Don't retry on 404 - endpoint may not exist yet
|
||||||
@@ -65,12 +74,26 @@ export const useService = (id: string) => {
|
|||||||
interface ServiceInput {
|
interface ServiceInput {
|
||||||
name: string;
|
name: string;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
price: number;
|
price?: number; // Price in dollars
|
||||||
|
price_cents?: number; // Price in cents (preferred)
|
||||||
description?: string;
|
description?: string;
|
||||||
photos?: string[];
|
photos?: string[];
|
||||||
variable_pricing?: boolean;
|
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;
|
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({
|
return useMutation({
|
||||||
mutationFn: async (serviceData: ServiceInput) => {
|
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> = {
|
const backendData: Record<string, any> = {
|
||||||
name: serviceData.name,
|
name: serviceData.name,
|
||||||
duration: serviceData.durationMinutes,
|
duration: serviceData.durationMinutes,
|
||||||
price: serviceData.price.toString(),
|
price: priceInDollars,
|
||||||
description: serviceData.description || '',
|
description: serviceData.description || '',
|
||||||
photos: serviceData.photos || [],
|
photos: serviceData.photos || [],
|
||||||
};
|
};
|
||||||
@@ -93,13 +121,29 @@ export const useCreateService = () => {
|
|||||||
if (serviceData.variable_pricing !== undefined) {
|
if (serviceData.variable_pricing !== undefined) {
|
||||||
backendData.variable_pricing = serviceData.variable_pricing;
|
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;
|
backendData.deposit_amount = serviceData.deposit_amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serviceData.deposit_percent !== undefined) {
|
if (serviceData.deposit_percent !== undefined) {
|
||||||
backendData.deposit_percent = serviceData.deposit_percent;
|
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);
|
const { data } = await apiClient.post('/services/', backendData);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -120,14 +164,38 @@ export const useUpdateService = () => {
|
|||||||
const backendData: Record<string, any> = {};
|
const backendData: Record<string, any> = {};
|
||||||
if (updates.name) backendData.name = updates.name;
|
if (updates.name) backendData.name = updates.name;
|
||||||
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
|
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.description !== undefined) backendData.description = updates.description;
|
||||||
if (updates.photos !== undefined) backendData.photos = updates.photos;
|
if (updates.photos !== undefined) backendData.photos = updates.photos;
|
||||||
|
|
||||||
// Pricing fields
|
// Pricing fields
|
||||||
if (updates.variable_pricing !== undefined) backendData.variable_pricing = updates.variable_pricing;
|
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;
|
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);
|
const { data } = await apiClient.patch(`/services/${id}/`, backendData);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,31 @@ export const useUpdatePage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useCreatePage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: { title: string; slug?: string; is_home?: boolean }) => {
|
||||||
|
const response = await api.post('/sites/me/pages/', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeletePage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await api.delete(`/sites/me/pages/${id}/`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const usePublicPage = () => {
|
export const usePublicPage = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['publicPage'],
|
queryKey: ['publicPage'],
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ export const useBlockedDates = (params: BlockedDatesParams) => {
|
|||||||
queryParams.append('include_business', String(params.include_business));
|
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) => ({
|
return data.blocked_dates.map((block: any) => ({
|
||||||
...block,
|
...block,
|
||||||
resource_id: block.resource_id ? String(block.resource_id) : null,
|
resource_id: block.resource_id ? String(block.resource_id) : null,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SettingsSidebarSection,
|
SettingsSidebarSection,
|
||||||
@@ -109,6 +110,12 @@ const SettingsLayout: React.FC = () => {
|
|||||||
label={t('settings.booking.title', 'Booking')}
|
label={t('settings.booking.title', 'Booking')}
|
||||||
description={t('settings.booking.description', 'Booking URL, redirects')}
|
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>
|
</SettingsSidebarSection>
|
||||||
|
|
||||||
{/* Branding Section */}
|
{/* Branding Section */}
|
||||||
|
|||||||
260
frontend/src/pages/BookingFlow.tsx
Normal file
260
frontend/src/pages/BookingFlow.tsx
Normal 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;
|
||||||
@@ -1356,8 +1356,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
|
|
||||||
// Separate business and resource blocks
|
// Separate business and resource blocks
|
||||||
const businessBlocks = dateBlocks.filter(b => b.resource_id === null);
|
const businessBlocks = dateBlocks.filter(b => b.resource_id === null);
|
||||||
const hasBusinessHard = businessBlocks.some(b => b.block_type === 'HARD');
|
// Only mark as closed if there's an all-day BUSINESS_CLOSED block
|
||||||
const hasBusinessSoft = businessBlocks.some(b => b.block_type === 'SOFT');
|
const isBusinessClosed = businessBlocks.some(b => b.all_day && b.purpose === 'BUSINESS_CLOSED');
|
||||||
|
|
||||||
// Group resource blocks by resource - maintain resource order
|
// Group resource blocks by resource - maintain resource order
|
||||||
const resourceBlocksByResource = resources.map(resource => {
|
const resourceBlocksByResource = resources.map(resource => {
|
||||||
@@ -1370,11 +1370,10 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
};
|
};
|
||||||
}).filter(rb => rb.blocks.length > 0);
|
}).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 = () => {
|
const getBgClass = () => {
|
||||||
if (date && date.getMonth() !== viewDate.getMonth()) return 'bg-gray-100 dark:bg-gray-800/70 opacity-50';
|
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 (isBusinessClosed) return 'bg-gray-100 dark:bg-gray-700/50';
|
||||||
if (hasBusinessSoft) return 'bg-yellow-50 dark:bg-yellow-900/20';
|
|
||||||
if (date) return 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800';
|
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';
|
return 'bg-gray-50 dark:bg-gray-800/50';
|
||||||
};
|
};
|
||||||
@@ -1396,18 +1395,6 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
|||||||
}`}>
|
}`}>
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
</div>
|
</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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{displayedAppointments.map(apt => {
|
{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) */}
|
{/* Appointments (including preview) */}
|
||||||
{layout.appointments.map(apt => {
|
{layout.appointments.map(apt => {
|
||||||
const left = apt.startMinutes * OVERLAY_PIXELS_PER_MINUTE;
|
const left = apt.startMinutes * OVERLAY_PIXELS_PER_MINUTE;
|
||||||
|
|||||||
@@ -2,52 +2,201 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Puck } from "@measured/puck";
|
import { Puck } from "@measured/puck";
|
||||||
import "@measured/puck/puck.css";
|
import "@measured/puck/puck.css";
|
||||||
import { config } from "../puckConfig";
|
import { config } from "../puckConfig";
|
||||||
import { usePages, useUpdatePage } from "../hooks/useSites";
|
import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, Plus, Trash2, FileText } from "lucide-react";
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
export const PageEditor: React.FC = () => {
|
export const PageEditor: React.FC = () => {
|
||||||
const { data: pages, isLoading } = usePages();
|
const { data: pages, isLoading } = usePages();
|
||||||
|
const { user } = useAuth();
|
||||||
const updatePage = useUpdatePage();
|
const updatePage = useUpdatePage();
|
||||||
|
const createPage = useCreatePage();
|
||||||
|
const deletePage = useDeletePage();
|
||||||
const [data, setData] = useState<any>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (homePage?.puck_data) {
|
if (currentPage?.puck_data) {
|
||||||
// Ensure data structure is valid for Puck
|
// Ensure data structure is valid for Puck
|
||||||
const puckData = homePage.puck_data;
|
const puckData = currentPage.puck_data;
|
||||||
if (!puckData.content) puckData.content = [];
|
if (!puckData.content) puckData.content = [];
|
||||||
if (!puckData.root) puckData.root = {};
|
if (!puckData.root) puckData.root = {};
|
||||||
setData(puckData);
|
setData(puckData);
|
||||||
} else if (homePage) {
|
} else if (currentPage) {
|
||||||
setData({ content: [], root: {} });
|
setData({ content: [], root: {} });
|
||||||
}
|
}
|
||||||
}, [homePage]);
|
}, [currentPage]);
|
||||||
|
|
||||||
const handlePublish = async (newData: any) => {
|
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 {
|
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!");
|
toast.success("Page published successfully!");
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
toast.error("Failed to publish page.");
|
const errorMsg = error?.response?.data?.error || "Failed to publish page.";
|
||||||
|
toast.error(errorMsg);
|
||||||
console.error(error);
|
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) {
|
if (isLoading) {
|
||||||
return <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>;
|
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>;
|
return <div>No page found. Please contact support.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) return null;
|
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 (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<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
|
<Puck
|
||||||
config={config}
|
config={config}
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
max_users: 5,
|
max_users: 5,
|
||||||
max_resources: 10,
|
max_resources: 10,
|
||||||
|
max_pages: 1,
|
||||||
contact_email: '',
|
contact_email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
can_manage_oauth_credentials: false,
|
can_manage_oauth_credentials: false,
|
||||||
@@ -37,6 +38,7 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
max_users: 5,
|
max_users: 5,
|
||||||
max_resources: 10,
|
max_resources: 10,
|
||||||
|
max_pages: 1,
|
||||||
contact_email: '',
|
contact_email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
can_manage_oauth_credentials: false,
|
can_manage_oauth_credentials: false,
|
||||||
@@ -91,6 +93,7 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
|
|||||||
is_active: createForm.is_active,
|
is_active: createForm.is_active,
|
||||||
max_users: createForm.max_users,
|
max_users: createForm.max_users,
|
||||||
max_resources: createForm.max_resources,
|
max_resources: createForm.max_resources,
|
||||||
|
max_pages: createForm.max_pages,
|
||||||
can_manage_oauth_credentials: createForm.can_manage_oauth_credentials,
|
can_manage_oauth_credentials: createForm.can_manage_oauth_credentials,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -258,7 +261,7 @@ const BusinessCreateModal: React.FC<BusinessCreateModalProps> = ({ isOpen, onClo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Limits */}
|
{/* Limits */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Max Users
|
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"
|
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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,47 +9,57 @@ import FeaturesPermissionsEditor, { PERMISSION_DEFINITIONS, getPermissionKey } f
|
|||||||
const TIER_DEFAULTS: Record<string, {
|
const TIER_DEFAULTS: Record<string, {
|
||||||
max_users: number;
|
max_users: number;
|
||||||
max_resources: number;
|
max_resources: number;
|
||||||
|
max_pages: number;
|
||||||
can_manage_oauth_credentials: boolean;
|
can_manage_oauth_credentials: boolean;
|
||||||
can_accept_payments: boolean;
|
can_accept_payments: boolean;
|
||||||
can_use_custom_domain: boolean;
|
can_use_custom_domain: boolean;
|
||||||
can_white_label: boolean;
|
can_white_label: boolean;
|
||||||
can_api_access: boolean;
|
can_api_access: boolean;
|
||||||
|
can_customize_booking_page: boolean;
|
||||||
}> = {
|
}> = {
|
||||||
FREE: {
|
FREE: {
|
||||||
max_users: 2,
|
max_users: 2,
|
||||||
max_resources: 5,
|
max_resources: 5,
|
||||||
|
max_pages: 1,
|
||||||
can_manage_oauth_credentials: false,
|
can_manage_oauth_credentials: false,
|
||||||
can_accept_payments: false,
|
can_accept_payments: false,
|
||||||
can_use_custom_domain: false,
|
can_use_custom_domain: false,
|
||||||
can_white_label: false,
|
can_white_label: false,
|
||||||
can_api_access: false,
|
can_api_access: false,
|
||||||
|
can_customize_booking_page: false,
|
||||||
},
|
},
|
||||||
STARTER: {
|
STARTER: {
|
||||||
max_users: 5,
|
max_users: 5,
|
||||||
max_resources: 15,
|
max_resources: 15,
|
||||||
|
max_pages: 3,
|
||||||
can_manage_oauth_credentials: false,
|
can_manage_oauth_credentials: false,
|
||||||
can_accept_payments: true,
|
can_accept_payments: true,
|
||||||
can_use_custom_domain: false,
|
can_use_custom_domain: false,
|
||||||
can_white_label: false,
|
can_white_label: false,
|
||||||
can_api_access: false,
|
can_api_access: false,
|
||||||
|
can_customize_booking_page: true,
|
||||||
},
|
},
|
||||||
PROFESSIONAL: {
|
PROFESSIONAL: {
|
||||||
max_users: 15,
|
max_users: 15,
|
||||||
max_resources: 50,
|
max_resources: 50,
|
||||||
|
max_pages: 10,
|
||||||
can_manage_oauth_credentials: false,
|
can_manage_oauth_credentials: false,
|
||||||
can_accept_payments: true,
|
can_accept_payments: true,
|
||||||
can_use_custom_domain: true,
|
can_use_custom_domain: true,
|
||||||
can_white_label: false,
|
can_white_label: false,
|
||||||
can_api_access: true,
|
can_api_access: true,
|
||||||
|
can_customize_booking_page: true,
|
||||||
},
|
},
|
||||||
ENTERPRISE: {
|
ENTERPRISE: {
|
||||||
max_users: -1, // unlimited
|
max_users: -1, // unlimited
|
||||||
max_resources: -1, // unlimited
|
max_resources: -1, // unlimited
|
||||||
|
max_pages: -1, // unlimited
|
||||||
can_manage_oauth_credentials: true,
|
can_manage_oauth_credentials: true,
|
||||||
can_accept_payments: true,
|
can_accept_payments: true,
|
||||||
can_use_custom_domain: true,
|
can_use_custom_domain: true,
|
||||||
can_white_label: true,
|
can_white_label: true,
|
||||||
can_api_access: true,
|
can_api_access: true,
|
||||||
|
can_customize_booking_page: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +80,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
// Limits
|
// Limits
|
||||||
max_users: 5,
|
max_users: 5,
|
||||||
max_resources: 10,
|
max_resources: 10,
|
||||||
|
max_pages: 1,
|
||||||
// Platform Permissions (flat, matching backend model)
|
// Platform Permissions (flat, matching backend model)
|
||||||
can_manage_oauth_credentials: false,
|
can_manage_oauth_credentials: false,
|
||||||
can_accept_payments: false,
|
can_accept_payments: false,
|
||||||
@@ -123,6 +134,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
// Limits
|
// Limits
|
||||||
max_users: plan.limits?.max_users ?? staticDefaults.max_users,
|
max_users: plan.limits?.max_users ?? staticDefaults.max_users,
|
||||||
max_resources: plan.limits?.max_resources ?? staticDefaults.max_resources,
|
max_resources: plan.limits?.max_resources ?? staticDefaults.max_resources,
|
||||||
|
max_pages: plan.limits?.max_pages ?? staticDefaults.max_pages,
|
||||||
// Platform Permissions
|
// Platform Permissions
|
||||||
can_manage_oauth_credentials: plan.permissions?.can_manage_oauth_credentials ?? staticDefaults.can_manage_oauth_credentials,
|
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,
|
can_accept_payments: plan.permissions?.can_accept_payments ?? staticDefaults.can_accept_payments,
|
||||||
@@ -201,6 +213,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
// Limits
|
// Limits
|
||||||
max_users: business.max_users || 5,
|
max_users: business.max_users || 5,
|
||||||
max_resources: business.max_resources || 10,
|
max_resources: business.max_resources || 10,
|
||||||
|
max_pages: business.max_pages || 1,
|
||||||
// Platform Permissions (flat, matching backend)
|
// Platform Permissions (flat, matching backend)
|
||||||
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
|
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
|
||||||
can_accept_payments: business.can_accept_payments || 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">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Use -1 for unlimited. These limits control what this business can create.
|
Use -1 for unlimited. These limits control what this business can create.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Max Users
|
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"
|
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>
|
||||||
|
<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>
|
||||||
</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 */}
|
{/* Features & Permissions - Using unified FeaturesPermissionsEditor */}
|
||||||
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<FeaturesPermissionsEditor
|
<FeaturesPermissionsEditor
|
||||||
|
|||||||
422
frontend/src/pages/settings/BusinessHoursSettings.tsx
Normal file
422
frontend/src/pages/settings/BusinessHoursSettings.tsx
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* Business Hours Settings
|
||||||
|
*
|
||||||
|
* Configure weekly operating hours that automatically block customer bookings
|
||||||
|
* outside those times while allowing staff manual override.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTimeBlocks, useCreateTimeBlock, useUpdateTimeBlock, useDeleteTimeBlock } from '../../hooks/useTimeBlocks';
|
||||||
|
import { Button, FormInput, Alert, LoadingSpinner, Card } from '../../components/ui';
|
||||||
|
import { BlockPurpose, TimeBlock } from '../../types';
|
||||||
|
|
||||||
|
interface DayHours {
|
||||||
|
enabled: boolean;
|
||||||
|
open: string; // "09:00"
|
||||||
|
close: string; // "17:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BusinessHours {
|
||||||
|
monday: DayHours;
|
||||||
|
tuesday: DayHours;
|
||||||
|
wednesday: DayHours;
|
||||||
|
thursday: DayHours;
|
||||||
|
friday: DayHours;
|
||||||
|
saturday: DayHours;
|
||||||
|
sunday: DayHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] as const;
|
||||||
|
const DAY_LABELS: Record<typeof DAYS[number], string> = {
|
||||||
|
monday: 'Monday',
|
||||||
|
tuesday: 'Tuesday',
|
||||||
|
wednesday: 'Wednesday',
|
||||||
|
thursday: 'Thursday',
|
||||||
|
friday: 'Friday',
|
||||||
|
saturday: 'Saturday',
|
||||||
|
sunday: 'Sunday',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_INDICES: Record<typeof DAYS[number], number> = {
|
||||||
|
monday: 0,
|
||||||
|
tuesday: 1,
|
||||||
|
wednesday: 2,
|
||||||
|
thursday: 3,
|
||||||
|
friday: 4,
|
||||||
|
saturday: 5,
|
||||||
|
sunday: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_HOURS: BusinessHours = {
|
||||||
|
monday: { enabled: true, open: '09:00', close: '17:00' },
|
||||||
|
tuesday: { enabled: true, open: '09:00', close: '17:00' },
|
||||||
|
wednesday: { enabled: true, open: '09:00', close: '17:00' },
|
||||||
|
thursday: { enabled: true, open: '09:00', close: '17:00' },
|
||||||
|
friday: { enabled: true, open: '09:00', close: '17:00' },
|
||||||
|
saturday: { enabled: false, open: '09:00', close: '17:00' },
|
||||||
|
sunday: { enabled: false, open: '09:00', close: '17:00' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BusinessHoursSettings: React.FC = () => {
|
||||||
|
const [hours, setHours] = useState<BusinessHours>(DEFAULT_HOURS);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [success, setSuccess] = useState<string>('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Fetch existing business hours time blocks
|
||||||
|
const { data: timeBlocks, isLoading } = useTimeBlocks({
|
||||||
|
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTimeBlock = useCreateTimeBlock();
|
||||||
|
const updateTimeBlock = useUpdateTimeBlock();
|
||||||
|
const deleteTimeBlock = useDeleteTimeBlock();
|
||||||
|
|
||||||
|
// Parse existing time blocks into UI state
|
||||||
|
useEffect(() => {
|
||||||
|
if (!timeBlocks || timeBlocks.length === 0) return;
|
||||||
|
|
||||||
|
const parsed: BusinessHours = { ...DEFAULT_HOURS };
|
||||||
|
|
||||||
|
// Group blocks by day
|
||||||
|
timeBlocks.forEach((block) => {
|
||||||
|
if (block.recurrence_type === 'WEEKLY' && block.recurrence_pattern?.days_of_week) {
|
||||||
|
const daysOfWeek = block.recurrence_pattern.days_of_week;
|
||||||
|
|
||||||
|
daysOfWeek.forEach((dayIndex) => {
|
||||||
|
const dayName = Object.keys(DAY_INDICES).find(
|
||||||
|
(key) => DAY_INDICES[key as typeof DAYS[number]] === dayIndex
|
||||||
|
) as typeof DAYS[number] | undefined;
|
||||||
|
|
||||||
|
if (dayName) {
|
||||||
|
// Check if this is a "before hours" or "after hours" block
|
||||||
|
if (block.start_time === '00:00:00') {
|
||||||
|
// Before hours block: 00:00 to open time
|
||||||
|
parsed[dayName].enabled = true;
|
||||||
|
parsed[dayName].open = block.end_time?.substring(0, 5) || '09:00';
|
||||||
|
} else if (block.end_time === '23:59:59' || block.end_time === '00:00:00') {
|
||||||
|
// After hours block: close time to 24:00
|
||||||
|
parsed[dayName].enabled = true;
|
||||||
|
parsed[dayName].close = block.start_time?.substring(0, 5) || '17:00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setHours(parsed);
|
||||||
|
}, [timeBlocks]);
|
||||||
|
|
||||||
|
const handleDayToggle = (day: typeof DAYS[number]) => {
|
||||||
|
setHours({
|
||||||
|
...hours,
|
||||||
|
[day]: {
|
||||||
|
...hours[day],
|
||||||
|
enabled: !hours[day].enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeChange = (day: typeof DAYS[number], field: 'open' | 'close', value: string) => {
|
||||||
|
setHours({
|
||||||
|
...hours,
|
||||||
|
[day]: {
|
||||||
|
...hours[day],
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateHours = (): boolean => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Check that enabled days have valid times
|
||||||
|
for (const day of DAYS) {
|
||||||
|
if (hours[day].enabled) {
|
||||||
|
const open = hours[day].open;
|
||||||
|
const close = hours[day].close;
|
||||||
|
|
||||||
|
if (!open || !close) {
|
||||||
|
setError(`Please set both open and close times for ${DAY_LABELS[day]}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open >= close) {
|
||||||
|
setError(`${DAY_LABELS[day]}: Close time must be after open time`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!validateHours()) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Starting save, existing blocks:', timeBlocks);
|
||||||
|
|
||||||
|
// Delete all existing business hours blocks
|
||||||
|
if (timeBlocks && timeBlocks.length > 0) {
|
||||||
|
console.log('Deleting', timeBlocks.length, 'existing blocks');
|
||||||
|
for (const block of timeBlocks) {
|
||||||
|
try {
|
||||||
|
await deleteTimeBlock.mutateAsync(block.id);
|
||||||
|
console.log('Deleted block:', block.id);
|
||||||
|
} catch (delErr: any) {
|
||||||
|
console.error('Error deleting block:', block.id, delErr);
|
||||||
|
throw new Error(`Failed to delete existing block: ${delErr.response?.data?.message || delErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group days by hours for efficient block creation
|
||||||
|
const hourGroups: Map<string, number[]> = new Map();
|
||||||
|
|
||||||
|
DAYS.forEach((day) => {
|
||||||
|
if (hours[day].enabled) {
|
||||||
|
const key = `${hours[day].open}-${hours[day].close}`;
|
||||||
|
const dayIndex = DAY_INDICES[day];
|
||||||
|
|
||||||
|
if (!hourGroups.has(key)) {
|
||||||
|
hourGroups.set(key, []);
|
||||||
|
}
|
||||||
|
hourGroups.get(key)!.push(dayIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Hour groups:', Array.from(hourGroups.entries()));
|
||||||
|
|
||||||
|
// Create new time blocks for each group
|
||||||
|
for (const [hoursKey, daysOfWeek] of hourGroups.entries()) {
|
||||||
|
const [open, close] = hoursKey.split('-');
|
||||||
|
|
||||||
|
// Before hours block: 00:00 to open time
|
||||||
|
try {
|
||||||
|
const beforeBlock = await createTimeBlock.mutateAsync({
|
||||||
|
title: 'Before Business Hours',
|
||||||
|
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
||||||
|
block_type: 'SOFT',
|
||||||
|
resource: null,
|
||||||
|
recurrence_type: 'WEEKLY',
|
||||||
|
recurrence_pattern: { days_of_week: daysOfWeek },
|
||||||
|
all_day: false,
|
||||||
|
start_time: '00:00:00',
|
||||||
|
end_time: `${open}:00`,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
console.log('Created before-hours block:', beforeBlock);
|
||||||
|
} catch (createErr: any) {
|
||||||
|
console.error('Error creating before-hours block:', createErr);
|
||||||
|
throw new Error(`Failed to create before-hours block: ${createErr.response?.data?.message || createErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After hours block: close time to 23:59:59
|
||||||
|
try {
|
||||||
|
const afterBlock = await createTimeBlock.mutateAsync({
|
||||||
|
title: 'After Business Hours',
|
||||||
|
purpose: 'BUSINESS_HOURS' as BlockPurpose,
|
||||||
|
block_type: 'SOFT',
|
||||||
|
resource: null,
|
||||||
|
recurrence_type: 'WEEKLY',
|
||||||
|
recurrence_pattern: { days_of_week: daysOfWeek },
|
||||||
|
all_day: false,
|
||||||
|
start_time: `${close}:00`,
|
||||||
|
end_time: '23:59:59',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
console.log('Created after-hours block:', afterBlock);
|
||||||
|
} catch (createErr: any) {
|
||||||
|
console.error('Error creating after-hours block:', createErr);
|
||||||
|
throw new Error(`Failed to create after-hours block: ${createErr.response?.data?.message || createErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Save completed successfully');
|
||||||
|
setSuccess('Business hours saved successfully! Customer bookings will be blocked outside these hours.');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Save error:', err);
|
||||||
|
setError(err.message || err.response?.data?.message || 'Failed to save business hours. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Business Hours</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Set your regular operating hours. Customer bookings will be blocked outside these times,
|
||||||
|
but staff can still manually schedule appointments if needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert variant="success" className="mb-4">
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 w-40">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${day}-enabled`}
|
||||||
|
checked={hours[day].enabled}
|
||||||
|
onChange={() => handleDayToggle(day)}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${day}-enabled`}
|
||||||
|
className="text-sm font-medium text-gray-900 dark:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
{DAY_LABELS[day]}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hours[day].enabled ? (
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Open:</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={hours[day].open}
|
||||||
|
onChange={(e) => handleTimeChange(day, 'open', e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Close:</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={hours[day].close}
|
||||||
|
onChange={(e) => handleTimeChange(day, 'close', e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
({calculateHours(hours[day].open, hours[day].close)} hours)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 flex-1">
|
||||||
|
Closed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<strong>Note:</strong> These hours apply to customer bookings only. Staff can override.
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Business Hours'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<Card className="mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Preview
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<div key={day} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{DAY_LABELS[day]}:
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
{hours[day].enabled
|
||||||
|
? `${formatTime(hours[day].open)} - ${formatTime(hours[day].close)}`
|
||||||
|
: 'Closed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const calculateHours = (open: string, close: string): string => {
|
||||||
|
try {
|
||||||
|
if (!open || !close || !open.includes(':') || !close.includes(':')) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [openHour, openMin] = open.split(':').map(Number);
|
||||||
|
const [closeHour, closeMin] = close.split(':').map(Number);
|
||||||
|
|
||||||
|
if (isNaN(openHour) || isNaN(openMin) || isNaN(closeHour) || isNaN(closeMin)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinutes = (closeHour * 60 + closeMin) - (openHour * 60 + openMin);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (minutes === 0) return `${hours}`;
|
||||||
|
return `${hours}.${minutes < 10 ? '0' : ''}${minutes}`;
|
||||||
|
} catch (e) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (time: string): string => {
|
||||||
|
try {
|
||||||
|
if (!time || !time.includes(':')) {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hour, min] = time.split(':').map(Number);
|
||||||
|
|
||||||
|
if (isNaN(hour) || isNaN(min)) {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||||
|
return `${displayHour}:${min.toString().padStart(2, '0')} ${period}`;
|
||||||
|
} catch (e) {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BusinessHoursSettings;
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Config } from "@measured/puck";
|
import type { Config } from "@measured/puck";
|
||||||
import BookingWidget from "./components/booking/BookingWidget";
|
import BookingWidget from "./components/booking/BookingWidget";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
Hero: { title: string; subtitle: string; align: "left" | "center" | "right"; backgroundColor: string; textColor: string };
|
Hero: {
|
||||||
TextSection: { heading: string; body: string; backgroundColor: string };
|
title: string;
|
||||||
Booking: { headline: string; subheading: string; accentColor: string; buttonLabel: 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> = {
|
export const config: Config<Props> = {
|
||||||
@@ -22,65 +29,91 @@ export const config: Config<Props> = {
|
|||||||
{ label: "Right", value: "right" },
|
{ label: "Right", value: "right" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
backgroundColor: { type: "text" }, // Puck doesn't have color picker by default? Or use "custom"?
|
ctaText: { type: "text", label: "Button Text" },
|
||||||
textColor: { type: "text" },
|
ctaLink: { type: "text", label: "Button Link" },
|
||||||
},
|
},
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
title: "Welcome to our site",
|
title: "Welcome to our site",
|
||||||
subtitle: "We provide great services",
|
subtitle: "We provide great services",
|
||||||
align: "center",
|
align: "center",
|
||||||
backgroundColor: "#ffffff",
|
ctaText: "Book Now",
|
||||||
textColor: "#000000",
|
ctaLink: "/book",
|
||||||
},
|
},
|
||||||
render: ({ title, subtitle, align, backgroundColor, textColor }) => (
|
render: ({ title, subtitle, align, ctaText, ctaLink }) => (
|
||||||
<div style={{ backgroundColor, color: textColor, padding: "4rem 2rem", textAlign: align }}>
|
<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">
|
||||||
<h1 style={{ fontSize: "3rem", fontWeight: "bold", marginBottom: "1rem" }}>{title}</h1>
|
<div className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"></div>
|
||||||
<p style={{ fontSize: "1.5rem" }}>{subtitle}</p>
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
</div>
|
<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: {
|
TextSection: {
|
||||||
fields: {
|
fields: {
|
||||||
heading: { type: "text" },
|
heading: { type: "text" },
|
||||||
body: { type: "textarea" },
|
body: { type: "textarea" },
|
||||||
backgroundColor: { type: "text" },
|
|
||||||
},
|
},
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
heading: "About Us",
|
heading: "About Us",
|
||||||
body: "Enter your text here...",
|
body: "Enter your text here...",
|
||||||
backgroundColor: "#f9fafb",
|
|
||||||
},
|
},
|
||||||
render: ({ heading, body, backgroundColor }) => (
|
render: ({ heading, body }) => (
|
||||||
<div style={{ backgroundColor, padding: "3rem 2rem" }}>
|
<section className="py-16 sm:py-20 bg-white dark:bg-gray-900">
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto" }}>
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<h2 style={{ fontSize: "2rem", marginBottom: "1rem" }}>{heading}</h2>
|
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
<div style={{ whiteSpace: "pre-wrap" }}>{body}</div>
|
{heading}
|
||||||
|
</h2>
|
||||||
|
<div className="text-lg text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Booking: {
|
Booking: {
|
||||||
fields: {
|
fields: {
|
||||||
headline: { type: "text" },
|
headline: { type: "text" },
|
||||||
subheading: { type: "text" },
|
subheading: { type: "text" },
|
||||||
accentColor: { type: "text" },
|
|
||||||
buttonLabel: { type: "text" },
|
|
||||||
},
|
},
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
headline: "Book an Appointment",
|
headline: "Schedule Your Appointment",
|
||||||
subheading: "Select a service below",
|
subheading: "Choose a service and time that works for you",
|
||||||
accentColor: "#2563eb",
|
|
||||||
buttonLabel: "Book Now",
|
|
||||||
},
|
},
|
||||||
render: ({ headline, subheading, accentColor, buttonLabel }) => (
|
render: ({ headline, subheading }) => (
|
||||||
<div style={{ padding: "3rem 2rem", textAlign: "center" }}>
|
<section className="py-16 sm:py-20 bg-gray-50 dark:bg-gray-800">
|
||||||
<BookingWidget
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
headline={headline}
|
<div className="text-center mb-12">
|
||||||
subheading={subheading}
|
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
accentColor={accentColor}
|
{headline}
|
||||||
buttonLabel={buttonLabel}
|
</h2>
|
||||||
/>
|
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||||
</div>
|
{subheading}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BookingWidget
|
||||||
|
headline={headline}
|
||||||
|
subheading={subheading}
|
||||||
|
accentColor="#4f46e5"
|
||||||
|
buttonLabel="Book Now"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export interface Business {
|
|||||||
stripeConnectAccountId?: string;
|
stripeConnectAccountId?: string;
|
||||||
websitePages?: Record<string, { name: string; content: PageComponent[] }>;
|
websitePages?: Record<string, { name: string; content: PageComponent[] }>;
|
||||||
customerDashboardContent?: 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;
|
trialStart?: string;
|
||||||
trialEnd?: string;
|
trialEnd?: string;
|
||||||
isTrialActive?: boolean;
|
isTrialActive?: boolean;
|
||||||
@@ -215,19 +218,45 @@ export interface Service {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
|
duration?: number; // Duration in minutes (backend field name)
|
||||||
price: number;
|
price: number;
|
||||||
|
price_cents?: number; // Price in cents
|
||||||
description: string;
|
description: string;
|
||||||
displayOrder: number;
|
displayOrder: number;
|
||||||
|
display_order?: number;
|
||||||
photos?: string[];
|
photos?: string[];
|
||||||
|
is_active?: boolean;
|
||||||
created_at?: string; // Used for quota overage calculation (oldest archived first)
|
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
|
is_archived_by_quota?: boolean; // True if archived due to quota overage
|
||||||
|
|
||||||
// Pricing fields
|
// Pricing fields
|
||||||
variable_pricing?: boolean; // If true, final price is determined after service completion
|
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)
|
deposit_percent?: number | null; // Deposit as percentage (only for fixed pricing)
|
||||||
requires_deposit?: boolean; // True if deposit configured (computed)
|
requires_deposit?: boolean; // True if deposit configured (computed)
|
||||||
requires_saved_payment_method?: boolean; // True if deposit > 0 or variable pricing (computed)
|
requires_saved_payment_method?: boolean; // True if deposit > 0 or variable pricing (computed)
|
||||||
deposit_display?: string | null; // Human-readable deposit description
|
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 {
|
export interface Metric {
|
||||||
@@ -548,6 +577,7 @@ export interface ContractPublicView {
|
|||||||
// --- Time Blocking Types ---
|
// --- Time Blocking Types ---
|
||||||
|
|
||||||
export type BlockType = 'HARD' | 'SOFT';
|
export type BlockType = 'HARD' | 'SOFT';
|
||||||
|
export type BlockPurpose = 'CLOSURE' | 'UNAVAILABLE' | 'BUSINESS_HOURS' | 'OTHER';
|
||||||
export type RecurrenceType = 'NONE' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'HOLIDAY';
|
export type RecurrenceType = 'NONE' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'HOLIDAY';
|
||||||
export type TimeBlockLevel = 'business' | 'resource';
|
export type TimeBlockLevel = 'business' | 'resource';
|
||||||
|
|
||||||
@@ -583,6 +613,7 @@ export interface TimeBlock {
|
|||||||
resource_name?: string;
|
resource_name?: string;
|
||||||
level: TimeBlockLevel;
|
level: TimeBlockLevel;
|
||||||
block_type: BlockType;
|
block_type: BlockType;
|
||||||
|
purpose: BlockPurpose;
|
||||||
recurrence_type: RecurrenceType;
|
recurrence_type: RecurrenceType;
|
||||||
start_date?: string; // ISO date string (for NONE type)
|
start_date?: string; // ISO date string (for NONE type)
|
||||||
end_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;
|
resource_name?: string;
|
||||||
level: TimeBlockLevel;
|
level: TimeBlockLevel;
|
||||||
block_type: BlockType;
|
block_type: BlockType;
|
||||||
|
purpose: BlockPurpose;
|
||||||
recurrence_type: RecurrenceType;
|
recurrence_type: RecurrenceType;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
@@ -635,6 +667,7 @@ export interface TimeBlockListItem {
|
|||||||
export interface BlockedDate {
|
export interface BlockedDate {
|
||||||
date: string; // ISO date string
|
date: string; // ISO date string
|
||||||
block_type: BlockType;
|
block_type: BlockType;
|
||||||
|
purpose: BlockPurpose;
|
||||||
title: string;
|
title: string;
|
||||||
resource_id: string | null;
|
resource_id: string | null;
|
||||||
all_day: boolean;
|
all_day: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: 'media', // Follow browser's color scheme preference
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from smoothschedule.identity.users.api_views import (
|
|||||||
hijack_acquire_view, hijack_release_view,
|
hijack_acquire_view, hijack_release_view,
|
||||||
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
|
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
|
||||||
invitation_details_view, accept_invitation_view, decline_invitation_view,
|
invitation_details_view, accept_invitation_view, decline_invitation_view,
|
||||||
check_subdomain_view, signup_view
|
check_subdomain_view, signup_view, send_customer_verification, verify_and_register_customer
|
||||||
)
|
)
|
||||||
from smoothschedule.identity.users.mfa_api_views import (
|
from smoothschedule.identity.users.mfa_api_views import (
|
||||||
mfa_status, send_phone_verification, verify_phone, enable_sms_mfa,
|
mfa_status, send_phone_verification, verify_phone, enable_sms_mfa,
|
||||||
@@ -115,6 +115,9 @@ urlpatterns += [
|
|||||||
path("auth/logout/", logout_view, name="logout"),
|
path("auth/logout/", logout_view, name="logout"),
|
||||||
path("auth/email/verify/send/", send_verification_email, name="send_verification_email"),
|
path("auth/email/verify/send/", send_verification_email, name="send_verification_email"),
|
||||||
path("auth/email/verify/", verify_email, name="verify_email"),
|
path("auth/email/verify/", verify_email, name="verify_email"),
|
||||||
|
# Customer verification for booking flow
|
||||||
|
path("auth/send-verification/", send_customer_verification, name="send_customer_verification"),
|
||||||
|
path("auth/verify-and-register/", verify_and_register_customer, name="verify_and_register_customer"),
|
||||||
# Hijack (masquerade) API
|
# Hijack (masquerade) API
|
||||||
path("auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
path("auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
||||||
path("auth/hijack/release/", hijack_release_view, name="hijack_release"),
|
path("auth/hijack/release/", hijack_release_view, name="hijack_release"),
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-11 05:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0023_add_can_use_contracts_field'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tenant',
|
||||||
|
name='max_pages',
|
||||||
|
field=models.IntegerField(default=1, help_text='Maximum number of public-facing web pages allowed in site builder'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-11 15:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0024_tenant_max_pages'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tenant',
|
||||||
|
name='can_customize_booking_page',
|
||||||
|
field=models.BooleanField(default=False, help_text='Whether this business can customize their public booking page and site builder'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-11 20:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0025_tenant_can_customize_booking_page'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tenant',
|
||||||
|
name='service_selection_heading',
|
||||||
|
field=models.CharField(blank=True, default='Choose your experience', help_text='Custom heading for service selection on booking page', max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tenant',
|
||||||
|
name='service_selection_subheading',
|
||||||
|
field=models.CharField(blank=True, default='Select a service to begin your booking.', help_text='Custom subheading for service selection on booking page', max_length=200),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -40,6 +40,10 @@ class Tenant(TenantMixin):
|
|||||||
# Feature flags
|
# Feature flags
|
||||||
max_users = models.IntegerField(default=5)
|
max_users = models.IntegerField(default=5)
|
||||||
max_resources = models.IntegerField(default=10)
|
max_resources = models.IntegerField(default=10)
|
||||||
|
max_pages = models.IntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Maximum number of public-facing web pages allowed in site builder"
|
||||||
|
)
|
||||||
|
|
||||||
# Branding
|
# Branding
|
||||||
logo = models.ImageField(
|
logo = models.ImageField(
|
||||||
@@ -103,6 +107,20 @@ class Tenant(TenantMixin):
|
|||||||
help_text="URL to redirect customers after they complete a booking (e.g., https://yourbusiness.com/thank-you)"
|
help_text="URL to redirect customers after they complete a booking (e.g., https://yourbusiness.com/thank-you)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Booking Page Customization
|
||||||
|
service_selection_heading = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
default='Choose your experience',
|
||||||
|
help_text="Custom heading for service selection on booking page"
|
||||||
|
)
|
||||||
|
service_selection_subheading = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
default='Select a service to begin your booking.',
|
||||||
|
help_text="Custom subheading for service selection on booking page"
|
||||||
|
)
|
||||||
|
|
||||||
# OAuth Settings - which providers are enabled for this business
|
# OAuth Settings - which providers are enabled for this business
|
||||||
oauth_enabled_providers = models.JSONField(
|
oauth_enabled_providers = models.JSONField(
|
||||||
default=list,
|
default=list,
|
||||||
@@ -231,6 +249,10 @@ class Tenant(TenantMixin):
|
|||||||
default=False,
|
default=False,
|
||||||
help_text="Whether this business can create and manage e-signature contracts"
|
help_text="Whether this business can create and manage e-signature contracts"
|
||||||
)
|
)
|
||||||
|
can_customize_booking_page = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether this business can customize their public booking page and site builder"
|
||||||
|
)
|
||||||
|
|
||||||
# Stripe Payment Configuration
|
# Stripe Payment Configuration
|
||||||
payment_mode = models.CharField(
|
payment_mode = models.CharField(
|
||||||
|
|||||||
@@ -1074,3 +1074,205 @@ def signup_view(request):
|
|||||||
# Cleanup if failed (transaction atomic would be better but this is simple)
|
# Cleanup if failed (transaction atomic would be better but this is simple)
|
||||||
# In a real app, use atomic transaction
|
# In a real app, use atomic transaction
|
||||||
return Response({"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def send_customer_verification(request):
|
||||||
|
"""
|
||||||
|
Send 6-digit verification code for new customer registration during booking.
|
||||||
|
POST /api/auth/send-verification/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
- email: Customer email address
|
||||||
|
- first_name: Customer first name
|
||||||
|
- last_name: Customer last name
|
||||||
|
|
||||||
|
Stores code in cache for 10 minutes.
|
||||||
|
"""
|
||||||
|
from django.core.cache import cache
|
||||||
|
import random
|
||||||
|
|
||||||
|
email = request.data.get('email', '').strip().lower()
|
||||||
|
first_name = request.data.get('first_name', '').strip()
|
||||||
|
last_name = request.data.get('last_name', '').strip()
|
||||||
|
|
||||||
|
if not email or not first_name:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Email and first name are required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if email is already registered
|
||||||
|
if User.objects.filter(email__iexact=email).exists():
|
||||||
|
return Response(
|
||||||
|
{'detail': 'An account with this email already exists. Please sign in instead.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate 6-digit code
|
||||||
|
verification_code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
|
||||||
|
|
||||||
|
# Store in cache with 10-minute expiry
|
||||||
|
cache_key = f'customer_verification:{email}'
|
||||||
|
cache.set(cache_key, {
|
||||||
|
'code': verification_code,
|
||||||
|
'first_name': first_name,
|
||||||
|
'last_name': last_name,
|
||||||
|
'email': email
|
||||||
|
}, timeout=600) # 10 minutes
|
||||||
|
|
||||||
|
# Send email with verification code
|
||||||
|
subject = "Your verification code - SmoothSchedule"
|
||||||
|
message = f"""Hi {first_name},
|
||||||
|
|
||||||
|
Thank you for booking with us! Your verification code is:
|
||||||
|
|
||||||
|
{verification_code}
|
||||||
|
|
||||||
|
This code will expire in 10 minutes.
|
||||||
|
|
||||||
|
If you didn't request this code, please ignore this email.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
The SmoothSchedule Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
|
||||||
|
[email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'detail': f'Failed to send email: {str(e)}'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'detail': 'Verification code sent successfully'
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def verify_and_register_customer(request):
|
||||||
|
"""
|
||||||
|
Verify code and create customer account for booking.
|
||||||
|
POST /api/auth/verify-and-register/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
- email: Customer email
|
||||||
|
- first_name: Customer first name
|
||||||
|
- last_name: Customer last name
|
||||||
|
- password: Account password
|
||||||
|
- verification_code: 6-digit code from email
|
||||||
|
|
||||||
|
Creates a customer user in the current tenant's schema.
|
||||||
|
"""
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django_tenants.utils import get_tenant_model
|
||||||
|
|
||||||
|
email = request.data.get('email', '').strip().lower()
|
||||||
|
first_name = request.data.get('first_name', '').strip()
|
||||||
|
last_name = request.data.get('last_name', '').strip()
|
||||||
|
password = request.data.get('password', '')
|
||||||
|
verification_code = request.data.get('verification_code', '').strip()
|
||||||
|
|
||||||
|
if not all([email, first_name, password, verification_code]):
|
||||||
|
return Response(
|
||||||
|
{'detail': 'All fields are required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get tenant from request
|
||||||
|
TenantModel = get_tenant_model()
|
||||||
|
tenant = getattr(request, 'tenant', None)
|
||||||
|
|
||||||
|
# Handle 'public' schema case (central API accessed via api.lvh.me)
|
||||||
|
if not tenant or tenant.schema_name == 'public':
|
||||||
|
# Try to find tenant from header
|
||||||
|
subdomain = request.headers.get('x-business-subdomain')
|
||||||
|
if subdomain:
|
||||||
|
try:
|
||||||
|
tenant = Tenant.objects.get(schema_name=subdomain)
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Business not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Invalid tenant context'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify code from cache
|
||||||
|
cache_key = f'customer_verification:{email}'
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if not cached_data:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Verification code expired or not found. Please request a new code.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if cached_data['code'] != verification_code:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Invalid verification code'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
if User.objects.filter(email__iexact=email).exists():
|
||||||
|
return Response(
|
||||||
|
{'detail': 'An account with this email already exists'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create customer user
|
||||||
|
try:
|
||||||
|
# Generate unique username from email
|
||||||
|
username_base = email.split('@')[0]
|
||||||
|
username = username_base
|
||||||
|
counter = 1
|
||||||
|
while User.objects.filter(username=username).exists():
|
||||||
|
username = f"{username_base}{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
role=User.Role.CUSTOMER,
|
||||||
|
tenant=tenant,
|
||||||
|
email_verified=True, # Email is verified via code
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
cache.delete(cache_key)
|
||||||
|
|
||||||
|
# Generate token
|
||||||
|
token, _ = Token.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'access': token.key,
|
||||||
|
'refresh': token.key,
|
||||||
|
'user': _get_user_data(user),
|
||||||
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error creating customer account: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return Response(
|
||||||
|
{'detail': f'Failed to create account: {str(e)}'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class TenantSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'subdomain', 'tier', 'is_active',
|
'id', 'name', 'subdomain', 'tier', 'is_active',
|
||||||
'created_on', 'user_count', 'owner', 'max_users',
|
'created_on', 'user_count', 'owner', 'max_users',
|
||||||
'max_resources', 'contact_email', 'phone',
|
'max_resources', 'max_pages', 'contact_email', 'phone',
|
||||||
# Platform permissions
|
# Platform permissions
|
||||||
'can_manage_oauth_credentials',
|
'can_manage_oauth_credentials',
|
||||||
'can_accept_payments',
|
'can_accept_payments',
|
||||||
@@ -228,6 +228,7 @@ class TenantSerializer(serializers.ModelSerializer):
|
|||||||
'can_use_webhooks',
|
'can_use_webhooks',
|
||||||
'can_use_calendar_sync',
|
'can_use_calendar_sync',
|
||||||
'can_use_contracts',
|
'can_use_contracts',
|
||||||
|
'can_customize_booking_page',
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
@@ -275,7 +276,7 @@ class TenantUpdateSerializer(serializers.ModelSerializer):
|
|||||||
model = Tenant
|
model = Tenant
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'is_active', 'subscription_tier',
|
'id', 'name', 'is_active', 'subscription_tier',
|
||||||
'max_users', 'max_resources',
|
'max_users', 'max_resources', 'max_pages',
|
||||||
# Platform permissions
|
# Platform permissions
|
||||||
'can_manage_oauth_credentials',
|
'can_manage_oauth_credentials',
|
||||||
'can_accept_payments',
|
'can_accept_payments',
|
||||||
@@ -300,6 +301,7 @@ class TenantUpdateSerializer(serializers.ModelSerializer):
|
|||||||
'can_use_webhooks',
|
'can_use_webhooks',
|
||||||
'can_use_calendar_sync',
|
'can_use_calendar_sync',
|
||||||
'can_use_contracts',
|
'can_use_contracts',
|
||||||
|
'can_customize_booking_page',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
|
|
||||||
@@ -330,6 +332,7 @@ class TenantCreateSerializer(serializers.Serializer):
|
|||||||
is_active = serializers.BooleanField(default=True)
|
is_active = serializers.BooleanField(default=True)
|
||||||
max_users = serializers.IntegerField(default=5, min_value=1)
|
max_users = serializers.IntegerField(default=5, min_value=1)
|
||||||
max_resources = serializers.IntegerField(default=10, min_value=1)
|
max_resources = serializers.IntegerField(default=10, min_value=1)
|
||||||
|
max_pages = serializers.IntegerField(default=1, min_value=1)
|
||||||
contact_email = serializers.EmailField(required=False, allow_blank=True)
|
contact_email = serializers.EmailField(required=False, allow_blank=True)
|
||||||
phone = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
phone = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
||||||
|
|
||||||
@@ -412,6 +415,7 @@ class TenantCreateSerializer(serializers.Serializer):
|
|||||||
is_active=validated_data.get('is_active', True),
|
is_active=validated_data.get('is_active', True),
|
||||||
max_users=validated_data.get('max_users', 5),
|
max_users=validated_data.get('max_users', 5),
|
||||||
max_resources=validated_data.get('max_resources', 10),
|
max_resources=validated_data.get('max_resources', 10),
|
||||||
|
max_pages=validated_data.get('max_pages', 1),
|
||||||
contact_email=validated_data.get('contact_email', ''),
|
contact_email=validated_data.get('contact_email', ''),
|
||||||
phone=validated_data.get('phone', ''),
|
phone=validated_data.get('phone', ''),
|
||||||
can_manage_oauth_credentials=validated_data.get('can_manage_oauth_credentials', False),
|
can_manage_oauth_credentials=validated_data.get('can_manage_oauth_credentials', False),
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Management command to create default home pages for all existing sites.
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from smoothschedule.platform.tenant_sites.models import Site, Page
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Create default home pages for all existing sites that do not have one, or populate empty ones'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
sites = Site.objects.all()
|
||||||
|
created_count = 0
|
||||||
|
populated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for site in sites:
|
||||||
|
home_page = site.pages.filter(is_home=True).first()
|
||||||
|
|
||||||
|
if not home_page:
|
||||||
|
# No home page exists - create one
|
||||||
|
self.stdout.write(f'Creating default page for {site.tenant.name}...')
|
||||||
|
site.create_default_page()
|
||||||
|
created_count += 1
|
||||||
|
elif not home_page.puck_data.get('content') or len(home_page.puck_data.get('content', [])) == 0:
|
||||||
|
# Home page exists but is empty - populate it
|
||||||
|
self.stdout.write(f'Populating empty page for {site.tenant.name}...')
|
||||||
|
default_content = {
|
||||||
|
'content': [
|
||||||
|
{
|
||||||
|
'type': 'Hero',
|
||||||
|
'props': {
|
||||||
|
'id': 'Hero-default',
|
||||||
|
'title': f'Welcome to {site.tenant.name}',
|
||||||
|
'subtitle': 'Book your appointment online',
|
||||||
|
'align': 'center',
|
||||||
|
'backgroundColor': '#f3f4f6',
|
||||||
|
'textColor': '#111827'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'Booking',
|
||||||
|
'props': {
|
||||||
|
'id': 'Booking-default',
|
||||||
|
'headline': 'Schedule Your Appointment',
|
||||||
|
'subheading': 'Choose a service and time that works for you',
|
||||||
|
'accentColor': '#4f46e5',
|
||||||
|
'buttonLabel': 'Book Now'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'root': {}
|
||||||
|
}
|
||||||
|
home_page.puck_data = default_content
|
||||||
|
home_page.save()
|
||||||
|
populated_count += 1
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Successfully created {created_count} new pages and populated {populated_count} empty pages. '
|
||||||
|
f'Skipped {skipped_count} sites with existing content.'
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Management command to update existing Hero components with CTA button props.
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from smoothschedule.platform.tenant_sites.models import Page
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Update existing Hero components with CTA button props'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
pages = Page.objects.filter(is_home=True)
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for page in pages:
|
||||||
|
content = page.puck_data.get('content', [])
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
for component in content:
|
||||||
|
if component.get('type') == 'Hero':
|
||||||
|
props = component.get('props', {})
|
||||||
|
|
||||||
|
# Add CTA button props if they don't exist
|
||||||
|
if 'ctaText' not in props:
|
||||||
|
props['ctaText'] = 'Book Now'
|
||||||
|
props['ctaLink'] = '/book'
|
||||||
|
props['buttonColor'] = '#4f46e5'
|
||||||
|
updated = True
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Updated Hero component in page: {page.title} ({page.site.tenant.name})'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
page.save()
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'\nSummary: Updated {updated_count} pages, skipped {skipped_count} pages'
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Management command to update existing pages to use Lumina design style.
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from smoothschedule.platform.tenant_sites.models import Page
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Update existing pages to use Lumina design style'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
pages = Page.objects.all()
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for page in pages:
|
||||||
|
content = page.puck_data.get('content', [])
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
for component in content:
|
||||||
|
if component.get('type') == 'Hero':
|
||||||
|
props = component.get('props', {})
|
||||||
|
|
||||||
|
# Remove old color props
|
||||||
|
if 'backgroundColor' in props:
|
||||||
|
del props['backgroundColor']
|
||||||
|
updated = True
|
||||||
|
if 'textColor' in props:
|
||||||
|
del props['textColor']
|
||||||
|
updated = True
|
||||||
|
if 'buttonColor' in props:
|
||||||
|
del props['buttonColor']
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Ensure CTA props exist
|
||||||
|
if 'ctaText' not in props:
|
||||||
|
props['ctaText'] = 'Book Now'
|
||||||
|
props['ctaLink'] = '/book'
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
elif component.get('type') == 'Booking':
|
||||||
|
props = component.get('props', {})
|
||||||
|
|
||||||
|
# Remove old color props
|
||||||
|
if 'accentColor' in props:
|
||||||
|
del props['accentColor']
|
||||||
|
updated = True
|
||||||
|
if 'buttonLabel' in props:
|
||||||
|
del props['buttonLabel']
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
elif component.get('type') == 'TextSection':
|
||||||
|
props = component.get('props', {})
|
||||||
|
|
||||||
|
# Remove old color props
|
||||||
|
if 'backgroundColor' in props:
|
||||||
|
del props['backgroundColor']
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
page.save()
|
||||||
|
updated_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Updated page: {page.title} ({page.site.tenant.name})'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'\nSummary: Updated {updated_count} pages to Lumina style'
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
from smoothschedule.identity.core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
|
|
||||||
class Site(models.Model):
|
class Site(models.Model):
|
||||||
@@ -13,6 +15,45 @@ class Site(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Site for {self.tenant.name}"
|
return f"Site for {self.tenant.name}"
|
||||||
|
|
||||||
|
def create_default_page(self):
|
||||||
|
"""Create a default home page for this site if one doesn't exist."""
|
||||||
|
if not self.pages.filter(is_home=True).exists():
|
||||||
|
default_content = {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "Hero",
|
||||||
|
"props": {
|
||||||
|
"id": "Hero-default",
|
||||||
|
"title": f"Welcome to {self.tenant.name}",
|
||||||
|
"subtitle": "Book your appointment online with ease",
|
||||||
|
"align": "center",
|
||||||
|
"ctaText": "Book Now",
|
||||||
|
"ctaLink": "/book"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Booking",
|
||||||
|
"props": {
|
||||||
|
"id": "Booking-default",
|
||||||
|
"headline": "Schedule Your Appointment",
|
||||||
|
"subheading": "Choose a service and time that works for you"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"root": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page.objects.create(
|
||||||
|
site=self,
|
||||||
|
slug="home",
|
||||||
|
path="/",
|
||||||
|
title="Home",
|
||||||
|
is_home=True,
|
||||||
|
is_published=True,
|
||||||
|
order=0,
|
||||||
|
puck_data=default_content
|
||||||
|
)
|
||||||
|
|
||||||
class Page(models.Model):
|
class Page(models.Model):
|
||||||
site = models.ForeignKey(Site, related_name="pages", on_delete=models.CASCADE)
|
site = models.ForeignKey(Site, related_name="pages", on_delete=models.CASCADE)
|
||||||
slug = models.SlugField(max_length=255)
|
slug = models.SlugField(max_length=255)
|
||||||
@@ -52,3 +93,10 @@ class Domain(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.host
|
return self.host
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Site)
|
||||||
|
def create_default_page_for_site(sender, instance, created, **kwargs):
|
||||||
|
"""Automatically create a default home page when a new Site is created."""
|
||||||
|
if created:
|
||||||
|
instance.create_default_page()
|
||||||
|
|||||||
@@ -29,15 +29,9 @@ class PublicPageSerializer(serializers.ModelSerializer):
|
|||||||
fields = ['title', 'puck_data']
|
fields = ['title', 'puck_data']
|
||||||
|
|
||||||
class PublicServiceSerializer(serializers.ModelSerializer):
|
class PublicServiceSerializer(serializers.ModelSerializer):
|
||||||
price = serializers.DecimalField(max_digits=10, decimal_places=2, source='price_cents') # converting cents? No, serializer source expects model field.
|
|
||||||
# Service model has price_cents (integer).
|
|
||||||
# Client expects dollars? or cents?
|
|
||||||
# Usually API returns cents or formatted.
|
|
||||||
# Let's return cents.
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
fields = ['id', 'name', 'description', 'duration', 'price_cents', 'deposit_amount_cents']
|
fields = ['id', 'name', 'description', 'duration', 'price_cents', 'deposit_amount_cents', 'photos']
|
||||||
|
|
||||||
class PublicBookingSerializer(serializers.ModelSerializer):
|
class PublicBookingSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
from rest_framework.test import APITestCase, APIClient
|
|
||||||
from django.urls import reverse
|
|
||||||
from smoothschedule.identity.core.models import Tenant, Domain as CoreDomain
|
|
||||||
from smoothschedule.platform.tenant_sites.models import Site, Page, Domain
|
|
||||||
from smoothschedule.identity.users.models import User
|
|
||||||
from rest_framework import status
|
|
||||||
from django_tenants.test.cases import TenantTestCase
|
|
||||||
from smoothschedule.commerce.billing.models import Plan
|
|
||||||
from django_tenants.utils import get_tenant_domain_model
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
|
|
||||||
class SiteAdminAPITest(TenantTestCase):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
# Determine domain
|
|
||||||
DomainModel = get_tenant_domain_model()
|
|
||||||
domain_obj = DomainModel.objects.filter(tenant=self.tenant).first()
|
|
||||||
if not domain_obj:
|
|
||||||
domain_obj = DomainModel.objects.create(domain="test.lvh.me", tenant=self.tenant, is_primary=True)
|
|
||||||
|
|
||||||
self.domain_url = domain_obj.domain
|
|
||||||
|
|
||||||
# Configure APIClient
|
|
||||||
self.client = APIClient()
|
|
||||||
self.client.defaults['HTTP_HOST'] = self.domain_url
|
|
||||||
|
|
||||||
# Create user with unique username
|
|
||||||
self.username = f"admin_{''.join(random.choices(string.ascii_lowercase, k=8))}"
|
|
||||||
self.user = User.objects.create_user(username=self.username, email="admin@example.com", password="password", role="owner")
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
# Create Site
|
|
||||||
self.site, _ = Site.objects.get_or_create(tenant=self.tenant)
|
|
||||||
|
|
||||||
# Create Page
|
|
||||||
self.page, _ = Page.objects.get_or_create(
|
|
||||||
site=self.site,
|
|
||||||
path="/",
|
|
||||||
defaults={
|
|
||||||
"slug": "home",
|
|
||||||
"title": "Home",
|
|
||||||
"is_home": True,
|
|
||||||
"puck_data": {"content": [{"type": "Booking", "props": {}}]}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Plan constraints
|
|
||||||
if not Plan.objects.filter(code="pro").exists():
|
|
||||||
self.plan = Plan.objects.create(name="Pro", code="pro", max_pages=5, allow_custom_domains=True, max_custom_domains=2)
|
|
||||||
|
|
||||||
def test_get_site_me(self):
|
|
||||||
url = "/sites/me/"
|
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data['tenant'], self.tenant.id)
|
|
||||||
|
|
||||||
def test_update_page(self):
|
|
||||||
url = f"/sites/me/pages/{self.page.id}/"
|
|
||||||
data = {
|
|
||||||
"title": "New Title",
|
|
||||||
"puck_data": {"content": [{"type": "Hero"}, {"type": "Booking"}]}
|
|
||||||
}
|
|
||||||
response = self.client.patch(url, data, format='json')
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.page.refresh_from_db()
|
|
||||||
self.assertEqual(self.page.title, "New Title")
|
|
||||||
|
|
||||||
def test_update_page_remove_booking_fail(self):
|
|
||||||
url = f"/sites/me/pages/{self.page.id}/"
|
|
||||||
data = {
|
|
||||||
"puck_data": {"content": [{"type": "Hero"}]} # No Booking
|
|
||||||
}
|
|
||||||
response = self.client.patch(url, data, format='json')
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def test_add_domain(self):
|
|
||||||
url = "/sites/me/domains/"
|
|
||||||
host = f"mycustom_{''.join(random.choices(string.ascii_lowercase, k=8))}.com"
|
|
||||||
data = {"host": host}
|
|
||||||
response = self.client.post(url, data, format='json')
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
||||||
self.assertTrue(Domain.objects.filter(host=host).exists())
|
|
||||||
|
|
||||||
def test_public_page_resolution(self):
|
|
||||||
url = "/public/page/"
|
|
||||||
self.client.logout()
|
|
||||||
self.client.force_authenticate(user=None)
|
|
||||||
|
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data['title'], "Home")
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from django.db import IntegrityError
|
|
||||||
from django.test import TestCase
|
|
||||||
from smoothschedule.identity.core.models import Tenant
|
|
||||||
from smoothschedule.platform.tenant_sites.models import Site, Page, Domain
|
|
||||||
from smoothschedule.commerce.billing.models import Plan
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestSiteModels(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
# Create Tenant (schema_name is required by django-tenants)
|
|
||||||
self.tenant = Tenant.objects.create(schema_name="testtenant", name="Test Tenant")
|
|
||||||
self.site = Site.objects.create(tenant=self.tenant)
|
|
||||||
|
|
||||||
def test_site_creation(self):
|
|
||||||
self.assertEqual(self.site.tenant, self.tenant)
|
|
||||||
self.assertTrue(self.site.is_enabled)
|
|
||||||
self.assertIsNone(self.site.primary_domain)
|
|
||||||
|
|
||||||
def test_one_site_per_tenant(self):
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Site.objects.create(tenant=self.tenant)
|
|
||||||
|
|
||||||
def test_page_creation(self):
|
|
||||||
page = Page.objects.create(
|
|
||||||
site=self.site,
|
|
||||||
slug="home",
|
|
||||||
path="/",
|
|
||||||
title="Home",
|
|
||||||
is_home=True,
|
|
||||||
puck_data={"content": []}
|
|
||||||
)
|
|
||||||
self.assertEqual(page.site, self.site)
|
|
||||||
self.assertEqual(page.path, "/")
|
|
||||||
|
|
||||||
def test_page_unique_path_per_site(self):
|
|
||||||
Page.objects.create(
|
|
||||||
site=self.site,
|
|
||||||
slug="home",
|
|
||||||
path="/",
|
|
||||||
title="Home",
|
|
||||||
puck_data={}
|
|
||||||
)
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Page.objects.create(
|
|
||||||
site=self.site,
|
|
||||||
slug="home-2",
|
|
||||||
path="/",
|
|
||||||
title="Home 2",
|
|
||||||
puck_data={}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_domain_creation(self):
|
|
||||||
domain = Domain.objects.create(
|
|
||||||
site=self.site,
|
|
||||||
host="example.com",
|
|
||||||
is_primary=True
|
|
||||||
)
|
|
||||||
self.assertEqual(domain.site, self.site)
|
|
||||||
self.assertTrue(domain.is_primary)
|
|
||||||
|
|
||||||
def test_domain_host_unique(self):
|
|
||||||
Domain.objects.create(site=self.site, host="example.com")
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Domain.objects.create(site=self.site, host="example.com")
|
|
||||||
|
|
||||||
def test_only_one_primary_domain_per_site(self):
|
|
||||||
Domain.objects.create(site=self.site, host="example.com", is_primary=True)
|
|
||||||
d2 = Domain(site=self.site, host="example.org", is_primary=True)
|
|
||||||
# Using save() should trigger DB constraint if supported or we catch IntegrityError
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
d2.save()
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestPlanExtensions(TestCase):
|
|
||||||
def test_plan_has_site_fields(self):
|
|
||||||
# This test ensures the Plan model has been extended
|
|
||||||
# Plan uses 'code' not 'slug'
|
|
||||||
plan = Plan.objects.create(name="Free", code="free_tier")
|
|
||||||
self.assertTrue(hasattr(plan, 'max_pages'))
|
|
||||||
self.assertTrue(hasattr(plan, 'allow_custom_domains'))
|
|
||||||
self.assertTrue(hasattr(plan, 'max_custom_domains'))
|
|
||||||
|
|
||||||
# Check defaults
|
|
||||||
self.assertEqual(plan.max_pages, 1)
|
|
||||||
self.assertFalse(plan.allow_custom_domains)
|
|
||||||
self.assertEqual(plan.max_custom_domains, 0)
|
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import SiteViewSet, PageViewSet, DomainViewSet, PublicPageView, PublicServiceViewSet, PublicAvailabilityView, PublicBookingView, PublicPaymentIntentView
|
from .views import (
|
||||||
|
SiteViewSet, PageViewSet, DomainViewSet, PublicPageView, PublicServiceViewSet,
|
||||||
|
PublicAvailabilityView, PublicBusinessHoursView, PublicBookingView,
|
||||||
|
PublicPaymentIntentView, PublicBusinessInfoView
|
||||||
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'sites', SiteViewSet, basename='site')
|
router.register(r'sites', SiteViewSet, basename='site')
|
||||||
router.register(r'sites/me/pages', PageViewSet, basename='page')
|
router.register(r'pages', PageViewSet, basename='page')
|
||||||
router.register(r'sites/me/domains', DomainViewSet, basename='domain')
|
router.register(r'domains', DomainViewSet, basename='domain')
|
||||||
router.register(r'public/services', PublicServiceViewSet, basename='public-service')
|
router.register(r'public/services', PublicServiceViewSet, basename='public-service')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('public/page/', PublicPageView.as_view(), name='public-page'),
|
path('public/page/', PublicPageView.as_view(), name='public-page'),
|
||||||
|
path('public/business/', PublicBusinessInfoView.as_view(), name='public-business'),
|
||||||
path('public/availability/', PublicAvailabilityView.as_view(), name='public-availability'),
|
path('public/availability/', PublicAvailabilityView.as_view(), name='public-availability'),
|
||||||
|
path('public/business-hours/', PublicBusinessHoursView.as_view(), name='public-business-hours'),
|
||||||
path('public/bookings/', PublicBookingView.as_view(), name='public-booking'),
|
path('public/bookings/', PublicBookingView.as_view(), name='public-booking'),
|
||||||
path('public/payments/intent/', PublicPaymentIntentView.as_view(), name='public-payment-intent'),
|
path('public/payments/intent/', PublicPaymentIntentView.as_view(), name='public-payment-intent'),
|
||||||
]
|
]
|
||||||
@@ -25,6 +25,55 @@ class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.Upd
|
|||||||
serializer = self.get_serializer(site)
|
serializer = self.get_serializer(site)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get', 'post'], url_path='me/pages')
|
||||||
|
def me_pages(self, request):
|
||||||
|
"""Get or create pages for the current tenant's site."""
|
||||||
|
tenant = request.tenant
|
||||||
|
|
||||||
|
# Handle 'public' schema case (central API accessed via api.lvh.me)
|
||||||
|
if tenant.schema_name == 'public':
|
||||||
|
# Try to find tenant from header
|
||||||
|
subdomain = request.headers.get('x-business-subdomain')
|
||||||
|
if subdomain:
|
||||||
|
try:
|
||||||
|
tenant = Tenant.objects.get(schema_name=subdomain)
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
return Response({"error": "Tenant not found"}, status=404)
|
||||||
|
else:
|
||||||
|
return Response({"error": "Business subdomain required"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
site = Site.objects.get(tenant=tenant)
|
||||||
|
except Site.DoesNotExist:
|
||||||
|
return Response({"detail": "Site not found"}, status=404)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
pages = Page.objects.filter(site=site)
|
||||||
|
serializer = PageSerializer(pages, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
elif request.method == 'POST':
|
||||||
|
# Check if tenant has permission to customize booking page
|
||||||
|
if not tenant.can_customize_booking_page:
|
||||||
|
return Response({
|
||||||
|
"error": "Your plan does not include site customization. Please upgrade to a paid plan to create and edit pages."
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
# Check page limit
|
||||||
|
current_page_count = Page.objects.filter(site=site).count()
|
||||||
|
max_pages = tenant.max_pages
|
||||||
|
|
||||||
|
if max_pages != -1 and current_page_count >= max_pages:
|
||||||
|
return Response({
|
||||||
|
"error": f"Page limit reached. Your plan allows {max_pages} page(s). Please upgrade to create more pages."
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
serializer = PageSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save(site=site)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
class PageViewSet(viewsets.ModelViewSet):
|
class PageViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = PageSerializer
|
serializer_class = PageSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
@@ -32,6 +81,30 @@ class PageViewSet(viewsets.ModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Page.objects.filter(site__tenant=self.request.tenant)
|
return Page.objects.filter(site__tenant=self.request.tenant)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
# Check if tenant has permission to customize booking page
|
||||||
|
if not request.tenant.can_customize_booking_page:
|
||||||
|
return Response({
|
||||||
|
"error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages."
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
# Check if tenant has permission to customize booking page
|
||||||
|
if not request.tenant.can_customize_booking_page:
|
||||||
|
return Response({
|
||||||
|
"error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages."
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
# Check if tenant has permission to customize booking page
|
||||||
|
if not request.tenant.can_customize_booking_page:
|
||||||
|
return Response({
|
||||||
|
"error": "Your plan does not include site customization. Please upgrade to a paid plan to delete pages."
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
class DomainViewSet(viewsets.ModelViewSet):
|
class DomainViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = DomainSerializer
|
serializer_class = DomainSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
@@ -99,14 +172,243 @@ class PublicServiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
return Service.objects.filter(is_active=True)
|
return Service.objects.filter(is_active=True)
|
||||||
|
|
||||||
class PublicAvailabilityView(APIView):
|
class PublicAvailabilityView(APIView):
|
||||||
|
"""
|
||||||
|
Return available time slots for a service on a given date.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- service_id: ID of the service to check availability for
|
||||||
|
- date: Date to check availability (YYYY-MM-DD format)
|
||||||
|
|
||||||
|
Returns a list of time slots with availability status.
|
||||||
|
"""
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
from datetime import datetime, timedelta, time
|
||||||
|
from django.utils import timezone
|
||||||
|
from smoothschedule.scheduling.schedule.models import Resource, TimeBlock
|
||||||
|
from smoothschedule.scheduling.schedule.services import AvailabilityService
|
||||||
|
|
||||||
if request.tenant.schema_name == 'public':
|
if request.tenant.schema_name == 'public':
|
||||||
return Response([])
|
return Response({"error": "Invalid tenant"}, status=400)
|
||||||
return Response([
|
|
||||||
"2025-12-12T09:00:00Z",
|
service_id = request.query_params.get('service_id')
|
||||||
"2025-12-12T10:00:00Z",
|
date_str = request.query_params.get('date')
|
||||||
])
|
|
||||||
|
if not service_id or not date_str:
|
||||||
|
return Response({"error": "service_id and date parameters are required"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = Service.objects.get(id=service_id, is_active=True)
|
||||||
|
except Service.DoesNotExist:
|
||||||
|
return Response({"error": "Service not found"}, status=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return Response({"error": "Invalid date format. Use YYYY-MM-DD"}, status=400)
|
||||||
|
|
||||||
|
# Get business hours for this date
|
||||||
|
business_hours = self._get_business_hours_for_date(date)
|
||||||
|
|
||||||
|
# If business is closed on this day, return empty slots with closed flag
|
||||||
|
if not business_hours:
|
||||||
|
return Response({
|
||||||
|
"date": date_str,
|
||||||
|
"service_id": service_id,
|
||||||
|
"is_open": False,
|
||||||
|
"slots": []
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get resources that can perform this service
|
||||||
|
if service.all_resources:
|
||||||
|
resources = Resource.objects.filter(type__in=['STAFF', 'ROOM'])
|
||||||
|
else:
|
||||||
|
resource_ids = service.resource_ids or []
|
||||||
|
resources = Resource.objects.filter(id__in=resource_ids)
|
||||||
|
|
||||||
|
if not resources.exists():
|
||||||
|
resources = Resource.objects.filter(type='STAFF')[:1]
|
||||||
|
|
||||||
|
# Generate time slots based on business hours
|
||||||
|
all_slots = []
|
||||||
|
duration = timedelta(minutes=service.duration)
|
||||||
|
|
||||||
|
start_hour, start_min = business_hours['start']
|
||||||
|
end_hour, end_min = business_hours['end']
|
||||||
|
|
||||||
|
# Get business timezone from tenant
|
||||||
|
import zoneinfo
|
||||||
|
business_tz_name = getattr(request.tenant, 'timezone', 'America/New_York')
|
||||||
|
try:
|
||||||
|
business_tz = zoneinfo.ZoneInfo(business_tz_name)
|
||||||
|
except Exception:
|
||||||
|
business_tz = zoneinfo.ZoneInfo('America/New_York')
|
||||||
|
|
||||||
|
# Create slot times in business timezone, then convert to UTC for comparison
|
||||||
|
naive_start = datetime.combine(date, time(start_hour, start_min))
|
||||||
|
naive_end = datetime.combine(date, time(end_hour, end_min))
|
||||||
|
|
||||||
|
# Make aware in business timezone
|
||||||
|
current_time = naive_start.replace(tzinfo=business_tz)
|
||||||
|
end_of_day = naive_end.replace(tzinfo=business_tz)
|
||||||
|
|
||||||
|
# Get current time + buffer (30 min) to filter out past/imminent slots
|
||||||
|
now = timezone.now()
|
||||||
|
min_booking_time = now + timedelta(minutes=30)
|
||||||
|
|
||||||
|
while current_time + duration <= end_of_day:
|
||||||
|
# Skip time slots that have already passed (or are too close)
|
||||||
|
if current_time < min_booking_time:
|
||||||
|
current_time += timedelta(minutes=30)
|
||||||
|
continue
|
||||||
|
slot_end = current_time + duration
|
||||||
|
slot_available = False
|
||||||
|
|
||||||
|
# Check if at least one resource is available for this slot
|
||||||
|
# Pass service for prep_time/takedown_time buffer calculations
|
||||||
|
for resource in resources:
|
||||||
|
is_available, _, _ = AvailabilityService.check_availability(
|
||||||
|
resource,
|
||||||
|
current_time,
|
||||||
|
slot_end,
|
||||||
|
booking_context='customer_booking',
|
||||||
|
service=service
|
||||||
|
)
|
||||||
|
if is_available:
|
||||||
|
slot_available = True
|
||||||
|
break
|
||||||
|
|
||||||
|
all_slots.append({
|
||||||
|
"time": current_time.isoformat(),
|
||||||
|
"display": current_time.strftime("%-I:%M %p"),
|
||||||
|
"available": slot_available
|
||||||
|
})
|
||||||
|
|
||||||
|
# Move to next 30-minute slot
|
||||||
|
current_time += timedelta(minutes=30)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"date": date_str,
|
||||||
|
"service_id": service_id,
|
||||||
|
"is_open": True,
|
||||||
|
"business_hours": {
|
||||||
|
"start": f"{start_hour:02d}:{start_min:02d}",
|
||||||
|
"end": f"{end_hour:02d}:{end_min:02d}"
|
||||||
|
},
|
||||||
|
"slots": all_slots,
|
||||||
|
"business_timezone": business_tz_name,
|
||||||
|
"timezone_display_mode": getattr(request.tenant, 'timezone_display_mode', 'business'),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _get_business_hours_for_date(self, date):
|
||||||
|
"""
|
||||||
|
Get business hours for a specific date.
|
||||||
|
Returns dict with 'start' and 'end' tuples (hour, minute), or None if closed.
|
||||||
|
"""
|
||||||
|
from smoothschedule.scheduling.schedule.models import TimeBlock
|
||||||
|
|
||||||
|
# Get business hours blocks (purpose=BUSINESS_HOURS, resource=null for business-level)
|
||||||
|
business_hours_blocks = TimeBlock.objects.filter(
|
||||||
|
resource__isnull=True,
|
||||||
|
purpose=TimeBlock.Purpose.BUSINESS_HOURS,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# If no business hours blocks defined at all, default to 9-5 every day
|
||||||
|
if not business_hours_blocks.exists():
|
||||||
|
return {'start': (9, 0), 'end': (17, 0)}
|
||||||
|
|
||||||
|
# Check if any business hours block applies to this date
|
||||||
|
for block in business_hours_blocks:
|
||||||
|
if block.blocks_date(date):
|
||||||
|
# This business hours block applies to this date
|
||||||
|
if block.all_day:
|
||||||
|
# All day business hours (unusual but supported) - default 9-5
|
||||||
|
return {'start': (9, 0), 'end': (17, 0)}
|
||||||
|
elif block.start_time and block.end_time:
|
||||||
|
return {
|
||||||
|
'start': (block.start_time.hour, block.start_time.minute),
|
||||||
|
'end': (block.end_time.hour, block.end_time.minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Business hours are defined but none match this date - business is CLOSED
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PublicBusinessHoursView(APIView):
|
||||||
|
"""
|
||||||
|
Return business hours for a date range.
|
||||||
|
Used by booking calendar to show which days are open/closed.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- start_date: Start of range (YYYY-MM-DD)
|
||||||
|
- end_date: End of range (YYYY-MM-DD)
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from smoothschedule.scheduling.schedule.models import TimeBlock
|
||||||
|
|
||||||
|
if request.tenant.schema_name == 'public':
|
||||||
|
return Response({"error": "Invalid tenant"}, status=400)
|
||||||
|
|
||||||
|
start_str = request.query_params.get('start_date')
|
||||||
|
end_str = request.query_params.get('end_date')
|
||||||
|
|
||||||
|
if not start_str or not end_str:
|
||||||
|
return Response({"error": "start_date and end_date parameters are required"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_str, '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.strptime(end_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return Response({"error": "Invalid date format. Use YYYY-MM-DD"}, status=400)
|
||||||
|
|
||||||
|
# Limit range to 90 days to prevent abuse
|
||||||
|
if (end_date - start_date).days > 90:
|
||||||
|
return Response({"error": "Date range cannot exceed 90 days"}, status=400)
|
||||||
|
|
||||||
|
# Get business hours blocks
|
||||||
|
business_hours_blocks = TimeBlock.objects.filter(
|
||||||
|
resource__isnull=True,
|
||||||
|
purpose=TimeBlock.Purpose.BUSINESS_HOURS,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build list of open dates with hours
|
||||||
|
result = []
|
||||||
|
current_date = start_date
|
||||||
|
|
||||||
|
while current_date <= end_date:
|
||||||
|
day_info = {
|
||||||
|
"date": current_date.isoformat(),
|
||||||
|
"is_open": False,
|
||||||
|
"hours": None
|
||||||
|
}
|
||||||
|
|
||||||
|
for block in business_hours_blocks:
|
||||||
|
if block.blocks_date(current_date):
|
||||||
|
day_info["is_open"] = True
|
||||||
|
if not block.all_day and block.start_time and block.end_time:
|
||||||
|
day_info["hours"] = {
|
||||||
|
"start": block.start_time.strftime("%H:%M"),
|
||||||
|
"end": block.end_time.strftime("%H:%M")
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
day_info["hours"] = {"start": "09:00", "end": "17:00"}
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no business hours block found, default to open 9-5
|
||||||
|
if not day_info["is_open"] and not business_hours_blocks.exists():
|
||||||
|
day_info["is_open"] = True
|
||||||
|
day_info["hours"] = {"start": "09:00", "end": "17:00"}
|
||||||
|
|
||||||
|
result.append(day_info)
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
return Response({"dates": result})
|
||||||
|
|
||||||
class PublicBookingView(APIView):
|
class PublicBookingView(APIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
@@ -119,3 +421,31 @@ class PublicPaymentIntentView(APIView):
|
|||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
return Response({"client_secret": "test_secret"})
|
return Response({"client_secret": "test_secret"})
|
||||||
|
|
||||||
|
|
||||||
|
class PublicBusinessInfoView(APIView):
|
||||||
|
"""Return public business info including booking page customization."""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
tenant = request.tenant
|
||||||
|
|
||||||
|
# Handle 'public' schema case (central API)
|
||||||
|
if tenant.schema_name == 'public':
|
||||||
|
subdomain = request.headers.get('x-business-subdomain')
|
||||||
|
if subdomain:
|
||||||
|
try:
|
||||||
|
tenant = Tenant.objects.get(schema_name=subdomain)
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
return Response({"error": "Tenant not found"}, status=404)
|
||||||
|
else:
|
||||||
|
return Response({"error": "Business subdomain required"}, status=400)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"name": tenant.name,
|
||||||
|
"logo_url": tenant.logo.url if tenant.logo else None,
|
||||||
|
"primary_color": tenant.primary_color,
|
||||||
|
"secondary_color": tenant.secondary_color,
|
||||||
|
"service_selection_heading": tenant.service_selection_heading or "Choose your experience",
|
||||||
|
"service_selection_subheading": tenant.service_selection_subheading or "Select a service to begin your booking.",
|
||||||
|
})
|
||||||
@@ -206,6 +206,9 @@ def current_business_view(request):
|
|||||||
'timezone_display_mode': tenant.timezone_display_mode,
|
'timezone_display_mode': tenant.timezone_display_mode,
|
||||||
# Booking settings
|
# Booking settings
|
||||||
'booking_return_url': tenant.booking_return_url or '',
|
'booking_return_url': tenant.booking_return_url or '',
|
||||||
|
# Booking page customization
|
||||||
|
'service_selection_heading': tenant.service_selection_heading or 'Choose your experience',
|
||||||
|
'service_selection_subheading': tenant.service_selection_subheading or 'Select a service to begin your booking.',
|
||||||
# Other optional fields with defaults
|
# Other optional fields with defaults
|
||||||
'whitelabel_enabled': False,
|
'whitelabel_enabled': False,
|
||||||
'resources_can_reschedule': False,
|
'resources_can_reschedule': False,
|
||||||
@@ -268,6 +271,12 @@ def update_business_view(request):
|
|||||||
if 'booking_return_url' in request.data:
|
if 'booking_return_url' in request.data:
|
||||||
tenant.booking_return_url = request.data['booking_return_url'] or ''
|
tenant.booking_return_url = request.data['booking_return_url'] or ''
|
||||||
|
|
||||||
|
if 'service_selection_heading' in request.data:
|
||||||
|
tenant.service_selection_heading = request.data['service_selection_heading'] or 'Choose your experience'
|
||||||
|
|
||||||
|
if 'service_selection_subheading' in request.data:
|
||||||
|
tenant.service_selection_subheading = request.data['service_selection_subheading'] or 'Select a service to begin your booking.'
|
||||||
|
|
||||||
# Handle logo uploads (base64 data URLs)
|
# Handle logo uploads (base64 data URLs)
|
||||||
if 'logo_url' in request.data:
|
if 'logo_url' in request.data:
|
||||||
logo_data = request.data['logo_url']
|
logo_data = request.data['logo_url']
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-11 16:28
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0033_remove_service_deposit_amount_remove_service_price_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='timeblock',
|
||||||
|
name='purpose',
|
||||||
|
field=models.CharField(choices=[('CLOSURE', 'Business Closure (holiday, vacation)'), ('UNAVAILABLE', 'Resource Unavailable (time off, maintenance)'), ('BUSINESS_HOURS', 'Business Operating Hours (outside hours blocked)'), ('OTHER', 'Other (general blocking)')], db_index=True, default='OTHER', help_text='Purpose of this time block', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='timeblock',
|
||||||
|
index=models.Index(fields=['purpose', 'is_active'], name='schedule_ti_purpose_02bdce_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-11 19:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0034_add_purpose_to_timeblock'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# all_resources column already exists in some schemas, so we use
|
||||||
|
# SeparateDatabaseAndState to sync Django's state without trying to
|
||||||
|
# recreate the column. If the column doesn't exist, we add it.
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
state_operations=[
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='all_resources',
|
||||||
|
field=models.BooleanField(default=True, help_text='If true, service can be performed by any resource. If false, use resource_ids field.'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
database_operations=[
|
||||||
|
# Use raw SQL to conditionally add the column only if it doesn't exist
|
||||||
|
# IMPORTANT: Must check current_schema() for multi-tenant compatibility
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'schedule_service'
|
||||||
|
AND column_name = 'all_resources'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE schedule_service ADD COLUMN all_resources BOOLEAN DEFAULT TRUE NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""",
|
||||||
|
reverse_sql="""
|
||||||
|
ALTER TABLE schedule_service DROP COLUMN IF EXISTS all_resources;
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
state_operations=[
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='resource_ids',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='List of specific resource IDs that can perform this service (only used if all_resources=False)'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
database_operations=[
|
||||||
|
# Use raw SQL to conditionally add the column only if it doesn't exist
|
||||||
|
# IMPORTANT: Must check current_schema() for multi-tenant compatibility
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'schedule_service'
|
||||||
|
AND column_name = 'resource_ids'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE schedule_service ADD COLUMN resource_ids JSONB DEFAULT '[]'::jsonb NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""",
|
||||||
|
reverse_sql="""
|
||||||
|
ALTER TABLE schedule_service DROP COLUMN IF EXISTS resource_ids;
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-11 19:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def add_column_if_not_exists(table, column, column_type, default):
|
||||||
|
"""Generate SQL to conditionally add a column."""
|
||||||
|
return f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = '{table}'
|
||||||
|
AND column_name = '{column}'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE {table} ADD COLUMN {column} {column_type} DEFAULT {default} NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0035_add_service_resource_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# These columns may already exist in some schemas
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
state_operations=[
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='prep_time',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Preparation time in minutes before the service'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='takedown_time',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Cleanup/takedown time in minutes after the service'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='reminder_enabled',
|
||||||
|
field=models.BooleanField(default=False, help_text='Whether to send reminder notifications for this service'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='reminder_hours_before',
|
||||||
|
field=models.PositiveIntegerField(default=24, help_text='Hours before appointment to send reminder'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='reminder_email',
|
||||||
|
field=models.BooleanField(default=True, help_text='Send reminder via email'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='reminder_sms',
|
||||||
|
field=models.BooleanField(default=False, help_text='Send reminder via SMS'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='thank_you_email_enabled',
|
||||||
|
field=models.BooleanField(default=False, help_text='Send thank you email after service completion'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
database_operations=[
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=add_column_if_not_exists('schedule_service', 'prep_time', 'INTEGER', '0'),
|
||||||
|
reverse_sql='ALTER TABLE schedule_service DROP COLUMN IF EXISTS prep_time;',
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=add_column_if_not_exists('schedule_service', 'takedown_time', 'INTEGER', '0'),
|
||||||
|
reverse_sql='ALTER TABLE schedule_service DROP COLUMN IF EXISTS takedown_time;',
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=add_column_if_not_exists('schedule_service', 'reminder_enabled', 'BOOLEAN', 'FALSE'),
|
||||||
|
reverse_sql='ALTER TABLE schedule_service DROP COLUMN IF EXISTS reminder_enabled;',
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=add_column_if_not_exists('schedule_service', 'reminder_hours_before', 'INTEGER', '24'),
|
||||||
|
reverse_sql='ALTER TABLE schedule_service DROP COLUMN IF EXISTS reminder_hours_before;',
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=add_column_if_not_exists('schedule_service', 'reminder_email', 'BOOLEAN', 'TRUE'),
|
||||||
|
reverse_sql='ALTER TABLE schedule_service DROP COLUMN IF EXISTS reminder_email;',
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=add_column_if_not_exists('schedule_service', 'reminder_sms', 'BOOLEAN', 'FALSE'),
|
||||||
|
reverse_sql='ALTER TABLE schedule_service DROP COLUMN IF EXISTS reminder_sms;',
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=add_column_if_not_exists('schedule_service', 'thank_you_email_enabled', 'BOOLEAN', 'FALSE'),
|
||||||
|
reverse_sql='ALTER TABLE schedule_service DROP COLUMN IF EXISTS thank_you_email_enabled;',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -56,6 +56,49 @@ class Service(models.Model):
|
|||||||
help_text="Deposit as percentage of price (0-100), only for fixed pricing"
|
help_text="Deposit as percentage of price (0-100), only for fixed pricing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Resource assignment
|
||||||
|
all_resources = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="If true, service can be performed by any resource. If false, use resource_ids field."
|
||||||
|
)
|
||||||
|
resource_ids = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
help_text="List of specific resource IDs that can perform this service (only used if all_resources=False)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buffer times
|
||||||
|
prep_time = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Preparation time in minutes before the service"
|
||||||
|
)
|
||||||
|
takedown_time = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Cleanup/takedown time in minutes after the service"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notification settings
|
||||||
|
reminder_enabled = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether to send reminder notifications for this service"
|
||||||
|
)
|
||||||
|
reminder_hours_before = models.PositiveIntegerField(
|
||||||
|
default=24,
|
||||||
|
help_text="Hours before appointment to send reminder"
|
||||||
|
)
|
||||||
|
reminder_email = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Send reminder via email"
|
||||||
|
)
|
||||||
|
reminder_sms = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Send reminder via SMS"
|
||||||
|
)
|
||||||
|
thank_you_email_enabled = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Send thank you email after service completion"
|
||||||
|
)
|
||||||
|
|
||||||
# Quota overage archiving
|
# Quota overage archiving
|
||||||
is_archived_by_quota = models.BooleanField(
|
is_archived_by_quota = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -1715,6 +1758,12 @@ class TimeBlock(models.Model):
|
|||||||
PENDING = 'PENDING', 'Pending Review'
|
PENDING = 'PENDING', 'Pending Review'
|
||||||
DENIED = 'DENIED', 'Denied'
|
DENIED = 'DENIED', 'Denied'
|
||||||
|
|
||||||
|
class Purpose(models.TextChoices):
|
||||||
|
CLOSURE = 'CLOSURE', 'Business Closure (holiday, vacation)'
|
||||||
|
UNAVAILABLE = 'UNAVAILABLE', 'Resource Unavailable (time off, maintenance)'
|
||||||
|
BUSINESS_HOURS = 'BUSINESS_HOURS', 'Business Operating Hours (outside hours blocked)'
|
||||||
|
OTHER = 'OTHER', 'Other (general blocking)'
|
||||||
|
|
||||||
# Core identification
|
# Core identification
|
||||||
title = models.CharField(
|
title = models.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
@@ -1743,6 +1792,15 @@ class TimeBlock(models.Model):
|
|||||||
help_text="HARD prevents booking; SOFT shows warning but allows override"
|
help_text="HARD prevents booking; SOFT shows warning but allows override"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Block purpose
|
||||||
|
purpose = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Purpose.choices,
|
||||||
|
default=Purpose.OTHER,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Purpose of this time block"
|
||||||
|
)
|
||||||
|
|
||||||
# Recurrence configuration
|
# Recurrence configuration
|
||||||
recurrence_type = models.CharField(
|
recurrence_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@@ -1851,6 +1909,7 @@ class TimeBlock(models.Model):
|
|||||||
models.Index(fields=['recurrence_type', 'is_active']),
|
models.Index(fields=['recurrence_type', 'is_active']),
|
||||||
models.Index(fields=['start_date', 'end_date']),
|
models.Index(fields=['start_date', 'end_date']),
|
||||||
models.Index(fields=['approval_status']),
|
models.Index(fields=['approval_status']),
|
||||||
|
models.Index(fields=['purpose', 'is_active']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -1862,6 +1921,11 @@ class TimeBlock(models.Model):
|
|||||||
"""Check if this is a business-level block (affects all resources)."""
|
"""Check if this is a business-level block (affects all resources)."""
|
||||||
return self.resource is None
|
return self.resource is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_business_hours(self):
|
||||||
|
"""Check if this is a business hours definition block."""
|
||||||
|
return self.purpose == self.Purpose.BUSINESS_HOURS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_effective(self):
|
def is_effective(self):
|
||||||
"""Check if this block is effective (active and approved)."""
|
"""Check if this block is effective (active and approved)."""
|
||||||
@@ -1962,6 +2026,12 @@ class TimeBlock(models.Model):
|
|||||||
block_start = datetime.combine(current_date, self.start_time)
|
block_start = datetime.combine(current_date, self.start_time)
|
||||||
block_end = datetime.combine(current_date, self.end_time)
|
block_end = datetime.combine(current_date, self.end_time)
|
||||||
|
|
||||||
|
# Make timezone-aware if start_dt is timezone-aware
|
||||||
|
# Use the SAME timezone as start_dt to ensure correct comparison
|
||||||
|
if start_dt.tzinfo is not None:
|
||||||
|
block_start = block_start.replace(tzinfo=start_dt.tzinfo)
|
||||||
|
block_end = block_end.replace(tzinfo=start_dt.tzinfo)
|
||||||
|
|
||||||
# Check overlap: start_dt < block_end AND end_dt > block_start
|
# Check overlap: start_dt < block_end AND end_dt > block_start
|
||||||
if start_dt < block_end and end_dt > block_start:
|
if start_dt < block_end and end_dt > block_start:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class ServiceSerializer(serializers.ModelSerializer):
|
|||||||
deposit_display = serializers.SerializerMethodField()
|
deposit_display = serializers.SerializerMethodField()
|
||||||
requires_deposit = serializers.BooleanField(read_only=True)
|
requires_deposit = serializers.BooleanField(read_only=True)
|
||||||
requires_saved_payment_method = serializers.BooleanField(read_only=True)
|
requires_saved_payment_method = serializers.BooleanField(read_only=True)
|
||||||
|
resource_names = serializers.SerializerMethodField()
|
||||||
|
|
||||||
# Read as dollars from property, write converts to cents
|
# Read as dollars from property, write converts to cents
|
||||||
price = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
price = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
||||||
@@ -163,9 +164,11 @@ class ServiceSerializer(serializers.ModelSerializer):
|
|||||||
# Pricing fields
|
# Pricing fields
|
||||||
'variable_pricing', 'deposit_amount', 'deposit_amount_cents', 'deposit_percent',
|
'variable_pricing', 'deposit_amount', 'deposit_amount_cents', 'deposit_percent',
|
||||||
'requires_deposit', 'requires_saved_payment_method', 'deposit_display',
|
'requires_deposit', 'requires_saved_payment_method', 'deposit_display',
|
||||||
|
# Resource assignment
|
||||||
|
'all_resources', 'resource_ids', 'resource_names',
|
||||||
]
|
]
|
||||||
read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota',
|
read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota',
|
||||||
'deposit_display', 'requires_deposit', 'requires_saved_payment_method']
|
'deposit_display', 'requires_deposit', 'requires_saved_payment_method', 'resource_names']
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
"""Convert price/deposit_amount from dollars to cents for writing"""
|
"""Convert price/deposit_amount from dollars to cents for writing"""
|
||||||
@@ -198,6 +201,14 @@ class ServiceSerializer(serializers.ModelSerializer):
|
|||||||
return f"{obj.deposit_percent}% deposit"
|
return f"{obj.deposit_percent}% deposit"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_resource_names(self, obj):
|
||||||
|
"""Get names of assigned resources (for display when all_resources=False)"""
|
||||||
|
if obj.all_resources or not obj.resource_ids:
|
||||||
|
return []
|
||||||
|
# Fetch resource names for the IDs
|
||||||
|
resources = Resource.objects.filter(id__in=obj.resource_ids)
|
||||||
|
return [r.name for r in resources]
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
"""Validate deposit configuration"""
|
"""Validate deposit configuration"""
|
||||||
variable_pricing = attrs.get('variable_pricing', getattr(self.instance, 'variable_pricing', False))
|
variable_pricing = attrs.get('variable_pricing', getattr(self.instance, 'variable_pricing', False))
|
||||||
@@ -1294,7 +1305,7 @@ class TimeBlockSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'title', 'description',
|
'id', 'title', 'description',
|
||||||
'resource', 'resource_name', 'level',
|
'resource', 'resource_name', 'level',
|
||||||
'block_type', 'recurrence_type',
|
'block_type', 'purpose', 'recurrence_type',
|
||||||
'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
|
'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
|
||||||
'recurrence_pattern', 'pattern_display', 'holiday_name',
|
'recurrence_pattern', 'pattern_display', 'holiday_name',
|
||||||
'recurrence_start', 'recurrence_end',
|
'recurrence_start', 'recurrence_end',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class AvailabilityService:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_availability(resource, start_time, end_time, exclude_event_id=None):
|
def check_availability(resource, start_time, end_time, exclude_event_id=None, booking_context='staff_scheduling', service=None):
|
||||||
"""
|
"""
|
||||||
Check if resource has capacity for a new/updated event.
|
Check if resource has capacity for a new/updated event.
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ class AvailabilityService:
|
|||||||
start_time (datetime): Proposed event start
|
start_time (datetime): Proposed event start
|
||||||
end_time (datetime): Proposed event end
|
end_time (datetime): Proposed event end
|
||||||
exclude_event_id (int, optional): Event ID to exclude (when updating)
|
exclude_event_id (int, optional): Event ID to exclude (when updating)
|
||||||
|
booking_context (str, optional): Context for booking ('customer_booking' or 'staff_scheduling')
|
||||||
|
- 'customer_booking': Business hours enforced as HARD blocks
|
||||||
|
- 'staff_scheduling': Business hours soft warnings only (default)
|
||||||
|
service (Service, optional): Service being booked - uses its prep_time and takedown_time
|
||||||
|
for buffer calculations. If not provided, uses resource.buffer_duration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (is_available: bool, reason: str, soft_block_warnings: list)
|
tuple: (is_available: bool, reason: str, soft_block_warnings: list)
|
||||||
@@ -35,11 +40,24 @@ class AvailabilityService:
|
|||||||
- reason: Human-readable explanation
|
- reason: Human-readable explanation
|
||||||
- soft_block_warnings: List of soft block warnings (can be overridden)
|
- soft_block_warnings: List of soft block warnings (can be overridden)
|
||||||
"""
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
soft_block_warnings = []
|
soft_block_warnings = []
|
||||||
|
|
||||||
# Step 1: Check time blocks (business-level first, then resource-level)
|
# Calculate buffer times from service or resource
|
||||||
|
if service:
|
||||||
|
prep_buffer = timedelta(minutes=service.prep_time)
|
||||||
|
takedown_buffer = timedelta(minutes=service.takedown_time)
|
||||||
|
else:
|
||||||
|
prep_buffer = resource.buffer_duration
|
||||||
|
takedown_buffer = resource.buffer_duration
|
||||||
|
|
||||||
|
# Calculate the full window including prep and takedown
|
||||||
|
full_start = start_time - prep_buffer
|
||||||
|
full_end = end_time + takedown_buffer
|
||||||
|
|
||||||
|
# Step 1: Check time blocks for the SERVICE window (business hours, soft blocks)
|
||||||
block_result = AvailabilityService._check_time_blocks(
|
block_result = AvailabilityService._check_time_blocks(
|
||||||
resource, start_time, end_time
|
resource, start_time, end_time, booking_context=booking_context
|
||||||
)
|
)
|
||||||
|
|
||||||
if block_result['hard_blocked']:
|
if block_result['hard_blocked']:
|
||||||
@@ -47,11 +65,27 @@ class AvailabilityService:
|
|||||||
|
|
||||||
soft_block_warnings.extend(block_result['soft_warnings'])
|
soft_block_warnings.extend(block_result['soft_warnings'])
|
||||||
|
|
||||||
# Step 2: Calculate search window with buffer
|
# Step 2: Check HARD blocks only for prep/takedown windows
|
||||||
query_start = start_time - resource.buffer_duration
|
# (Prep/takedown can extend outside business hours but not through hard blocks)
|
||||||
query_end = end_time + resource.buffer_duration
|
if prep_buffer.total_seconds() > 0:
|
||||||
|
prep_block_result = AvailabilityService._check_time_blocks(
|
||||||
|
resource, full_start, start_time, booking_context='buffer_check'
|
||||||
|
)
|
||||||
|
if prep_block_result['hard_blocked']:
|
||||||
|
return False, f"Prep time blocked: {prep_block_result['reason']}", []
|
||||||
|
|
||||||
# Step 3: Find all events for this resource
|
if takedown_buffer.total_seconds() > 0:
|
||||||
|
takedown_block_result = AvailabilityService._check_time_blocks(
|
||||||
|
resource, end_time, full_end, booking_context='buffer_check'
|
||||||
|
)
|
||||||
|
if takedown_block_result['hard_blocked']:
|
||||||
|
return False, f"Takedown time blocked: {takedown_block_result['reason']}", []
|
||||||
|
|
||||||
|
# Step 3: Calculate search window for event conflicts (includes buffers)
|
||||||
|
query_start = full_start
|
||||||
|
query_end = full_end
|
||||||
|
|
||||||
|
# Step 4: Find all events for this resource
|
||||||
resource_content_type = ContentType.objects.get_for_model(Resource)
|
resource_content_type = ContentType.objects.get_for_model(Resource)
|
||||||
|
|
||||||
resource_participants = Participant.objects.filter(
|
resource_participants = Participant.objects.filter(
|
||||||
@@ -60,7 +94,7 @@ class AvailabilityService:
|
|||||||
role=Participant.Role.RESOURCE
|
role=Participant.Role.RESOURCE
|
||||||
).select_related('event')
|
).select_related('event')
|
||||||
|
|
||||||
# Step 4: Filter for overlapping events
|
# Step 5: Filter for overlapping events
|
||||||
overlapping_events = []
|
overlapping_events = []
|
||||||
for participant in resource_participants:
|
for participant in resource_participants:
|
||||||
event = participant.event
|
event = participant.event
|
||||||
@@ -81,7 +115,7 @@ class AvailabilityService:
|
|||||||
|
|
||||||
current_count = len(overlapping_events)
|
current_count = len(overlapping_events)
|
||||||
|
|
||||||
# Step 5: Check capacity limit
|
# Step 6: Check capacity limit
|
||||||
|
|
||||||
# CRITICAL: Handle infinite capacity (0 = unlimited)
|
# CRITICAL: Handle infinite capacity (0 = unlimited)
|
||||||
if resource.max_concurrent_events == 0:
|
if resource.max_concurrent_events == 0:
|
||||||
@@ -98,17 +132,26 @@ class AvailabilityService:
|
|||||||
return True, f"Available ({current_count + 1}/{resource.max_concurrent_events} slots)", soft_block_warnings
|
return True, f"Available ({current_count + 1}/{resource.max_concurrent_events} slots)", soft_block_warnings
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_time_blocks(resource, start_time, end_time):
|
def _check_time_blocks(resource, start_time, end_time, booking_context='staff_scheduling'):
|
||||||
"""
|
"""
|
||||||
Check if a time period is blocked by any time blocks.
|
Check if a time period is blocked by any time blocks.
|
||||||
|
|
||||||
Checks both business-level blocks (resource=null) and resource-level blocks.
|
Checks both business-level blocks (resource=null) and resource-level blocks.
|
||||||
Business-level blocks are checked first as they apply to all resources.
|
Business-level blocks are checked first as they apply to all resources.
|
||||||
|
|
||||||
|
For BUSINESS_HOURS blocks, the logic is INVERTED:
|
||||||
|
- BUSINESS_HOURS blocks define WHEN business IS OPEN
|
||||||
|
- Times OUTSIDE the block's window are blocked
|
||||||
|
- Times WITHIN the block's window are allowed
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
resource (Resource): The resource to check
|
resource (Resource): The resource to check
|
||||||
start_time (datetime): Proposed event start
|
start_time (datetime): Proposed event start
|
||||||
end_time (datetime): Proposed event end
|
end_time (datetime): Proposed event end
|
||||||
|
booking_context (str): Context for booking:
|
||||||
|
- 'customer_booking': Business hours enforced as HARD blocks
|
||||||
|
- 'staff_scheduling': Business hours soft warnings only (default)
|
||||||
|
- 'buffer_check': Only check HARD blocks (for prep/takedown windows)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {
|
dict: {
|
||||||
@@ -129,8 +172,34 @@ class AvailabilityService:
|
|||||||
is_active=True
|
is_active=True
|
||||||
).order_by('resource') # Business blocks first (null sorts first)
|
).order_by('resource') # Business blocks first (null sorts first)
|
||||||
|
|
||||||
for block in blocks:
|
# Separate business hours blocks from regular blocks
|
||||||
# Check if this block applies to the requested datetime range
|
business_hours_blocks = [b for b in blocks if b.is_business_hours]
|
||||||
|
regular_blocks = [b for b in blocks if not b.is_business_hours]
|
||||||
|
|
||||||
|
# For buffer_check context, skip business hours entirely
|
||||||
|
# (prep/takedown can extend outside business hours)
|
||||||
|
if booking_context != 'buffer_check':
|
||||||
|
# Check business hours first (if any are defined)
|
||||||
|
# For business hours, we check if time is OUTSIDE the defined hours
|
||||||
|
if business_hours_blocks:
|
||||||
|
is_within_business_hours = False
|
||||||
|
for block in business_hours_blocks:
|
||||||
|
# blocks_datetime_range returns True if the time overlaps with the block
|
||||||
|
if block.blocks_datetime_range(start_time, end_time):
|
||||||
|
is_within_business_hours = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_within_business_hours:
|
||||||
|
# Time is OUTSIDE business hours
|
||||||
|
if booking_context == 'customer_booking':
|
||||||
|
result['hard_blocked'] = True
|
||||||
|
result['reason'] = "Outside business hours"
|
||||||
|
return result
|
||||||
|
elif booking_context == 'staff_scheduling':
|
||||||
|
result['soft_warnings'].append("Outside business hours")
|
||||||
|
|
||||||
|
# Check regular blocks (closures, unavailable, etc.)
|
||||||
|
for block in regular_blocks:
|
||||||
if block.blocks_datetime_range(start_time, end_time):
|
if block.blocks_datetime_range(start_time, end_time):
|
||||||
if block.block_type == TimeBlock.BlockType.HARD:
|
if block.block_type == TimeBlock.BlockType.HARD:
|
||||||
# Hard block - immediately return unavailable
|
# Hard block - immediately return unavailable
|
||||||
@@ -139,9 +208,10 @@ class AvailabilityService:
|
|||||||
result['reason'] = f"{level}: {block.title}"
|
result['reason'] = f"{level}: {block.title}"
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
# Soft block - add warning but continue
|
# Soft block - add warning but continue (skip for buffer_check)
|
||||||
level = "Business advisory" if block.resource is None else f"{resource.name} advisory"
|
if booking_context != 'buffer_check':
|
||||||
result['soft_warnings'].append(f"{level}: {block.title}")
|
level = "Business advisory" if block.resource is None else f"{resource.name} advisory"
|
||||||
|
result['soft_warnings'].append(f"{level}: {block.title}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -1825,3 +1825,24 @@ class TestTimeBlockModel:
|
|||||||
assert blocked[0]['start_time'] == time(12, 0)
|
assert blocked[0]['start_time'] == time(12, 0)
|
||||||
assert blocked[0]['end_time'] == time(13, 0)
|
assert blocked[0]['end_time'] == time(13, 0)
|
||||||
assert blocked[0]['all_day'] is False
|
assert blocked[0]['all_day'] is False
|
||||||
|
|
||||||
|
def test_is_business_hours_true(self):
|
||||||
|
"""Test is_business_hours property when purpose is BUSINESS_HOURS."""
|
||||||
|
from smoothschedule.scheduling.schedule.models import TimeBlock
|
||||||
|
|
||||||
|
block = TimeBlock(purpose=TimeBlock.Purpose.BUSINESS_HOURS)
|
||||||
|
assert block.is_business_hours is True
|
||||||
|
|
||||||
|
def test_is_business_hours_false(self):
|
||||||
|
"""Test is_business_hours property when purpose is not BUSINESS_HOURS."""
|
||||||
|
from smoothschedule.scheduling.schedule.models import TimeBlock
|
||||||
|
|
||||||
|
block = TimeBlock(purpose=TimeBlock.Purpose.CLOSURE)
|
||||||
|
assert block.is_business_hours is False
|
||||||
|
|
||||||
|
def test_is_business_hours_default(self):
|
||||||
|
"""Test is_business_hours property with default purpose."""
|
||||||
|
from smoothschedule.scheduling.schedule.models import TimeBlock
|
||||||
|
|
||||||
|
block = TimeBlock() # Defaults to OTHER
|
||||||
|
assert block.is_business_hours is False
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ class TestTimeBlockChecking:
|
|||||||
mock_block.title = "Maintenance"
|
mock_block.title = "Maintenance"
|
||||||
mock_block.resource = mock_resource
|
mock_block.resource = mock_resource
|
||||||
mock_block.blocks_datetime_range.return_value = True
|
mock_block.blocks_datetime_range.return_value = True
|
||||||
|
mock_block.is_business_hours = False
|
||||||
|
|
||||||
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
||||||
mock_timeblock.BlockType.HARD = 'HARD'
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
@@ -288,6 +289,7 @@ class TestTimeBlockChecking:
|
|||||||
mock_block.title = "Reduced Staff"
|
mock_block.title = "Reduced Staff"
|
||||||
mock_block.resource = mock_resource
|
mock_block.resource = mock_resource
|
||||||
mock_block.blocks_datetime_range.return_value = True
|
mock_block.blocks_datetime_range.return_value = True
|
||||||
|
mock_block.is_business_hours = False
|
||||||
|
|
||||||
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
||||||
mock_timeblock.BlockType.HARD = 'HARD'
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
@@ -1155,3 +1157,352 @@ class TestWebhookPlugin:
|
|||||||
# Assert
|
# Assert
|
||||||
assert len(result['data']['response']) == 500
|
assert len(result['data']['response']) == 500
|
||||||
assert result['data']['response'] == 'x' * 500
|
assert result['data']['response'] == 'x' * 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestBusinessHoursBookingContext:
|
||||||
|
"""Test business hours checking with booking context."""
|
||||||
|
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.ContentType')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.Participant')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.TimeBlock')
|
||||||
|
def test_business_hours_block_customer_booking(
|
||||||
|
self, mock_timeblock, mock_participant, mock_contenttype
|
||||||
|
):
|
||||||
|
"""Test that OUTSIDE business hours blocks customer bookings.
|
||||||
|
|
||||||
|
When blocks_datetime_range returns False for business hours blocks,
|
||||||
|
it means the time is OUTSIDE business hours and should be blocked.
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
mock_resource = Mock()
|
||||||
|
mock_resource.max_concurrent_events = 10
|
||||||
|
mock_resource.buffer_duration = timedelta(minutes=0)
|
||||||
|
mock_resource.name = "Staff Member"
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
|
end = start + timedelta(hours=1)
|
||||||
|
|
||||||
|
# Mock business hours block (SOFT with BUSINESS_HOURS purpose)
|
||||||
|
mock_block = Mock()
|
||||||
|
mock_block.block_type = 'SOFT'
|
||||||
|
mock_block.title = "Business Hours"
|
||||||
|
mock_block.resource = None # Business-level
|
||||||
|
# blocks_datetime_range=False means time is OUTSIDE business hours
|
||||||
|
mock_block.blocks_datetime_range.return_value = False
|
||||||
|
mock_block.is_business_hours = True
|
||||||
|
mock_block.Purpose = Mock()
|
||||||
|
mock_block.Purpose.BUSINESS_HOURS = 'BUSINESS_HOURS'
|
||||||
|
mock_block.purpose = 'BUSINESS_HOURS'
|
||||||
|
|
||||||
|
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
||||||
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
|
|
||||||
|
# Mock no events
|
||||||
|
mock_contenttype.objects.get_for_model.return_value = Mock(id=1)
|
||||||
|
mock_participant.objects.filter.return_value.select_related.return_value = []
|
||||||
|
|
||||||
|
# Act
|
||||||
|
is_available, reason, warnings = AvailabilityService.check_availability(
|
||||||
|
mock_resource, start, end, booking_context='customer_booking'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert - Should be blocked because outside business hours
|
||||||
|
assert is_available is False
|
||||||
|
assert "outside business hours" in reason.lower()
|
||||||
|
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.ContentType')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.Participant')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.TimeBlock')
|
||||||
|
def test_business_hours_allow_staff_scheduling(
|
||||||
|
self, mock_timeblock, mock_participant, mock_contenttype
|
||||||
|
):
|
||||||
|
"""Test that staff can schedule OUTSIDE business hours with warning.
|
||||||
|
|
||||||
|
When blocks_datetime_range returns False for business hours blocks,
|
||||||
|
the time is outside business hours. Staff can still schedule but get a warning.
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
mock_resource = Mock()
|
||||||
|
mock_resource.max_concurrent_events = 10
|
||||||
|
mock_resource.buffer_duration = timedelta(minutes=0)
|
||||||
|
mock_resource.name = "Staff Member"
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
|
end = start + timedelta(hours=1)
|
||||||
|
|
||||||
|
# Mock business hours block
|
||||||
|
mock_block = Mock()
|
||||||
|
mock_block.block_type = 'SOFT'
|
||||||
|
mock_block.title = "Business Hours"
|
||||||
|
mock_block.resource = None
|
||||||
|
# blocks_datetime_range=False means time is OUTSIDE business hours
|
||||||
|
mock_block.blocks_datetime_range.return_value = False
|
||||||
|
mock_block.is_business_hours = True
|
||||||
|
mock_block.Purpose = Mock()
|
||||||
|
mock_block.Purpose.BUSINESS_HOURS = 'BUSINESS_HOURS'
|
||||||
|
mock_block.purpose = 'BUSINESS_HOURS'
|
||||||
|
|
||||||
|
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
||||||
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
|
|
||||||
|
# Mock no events
|
||||||
|
mock_contenttype.objects.get_for_model.return_value = Mock(id=1)
|
||||||
|
mock_participant.objects.filter.return_value.select_related.return_value = []
|
||||||
|
|
||||||
|
# Act
|
||||||
|
is_available, reason, warnings = AvailabilityService.check_availability(
|
||||||
|
mock_resource, start, end, booking_context='staff_scheduling'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert - Staff can schedule outside business hours but get a warning
|
||||||
|
assert is_available is True
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "outside business hours" in warnings[0].lower()
|
||||||
|
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.ContentType')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.Participant')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.TimeBlock')
|
||||||
|
def test_non_business_hours_block_not_affected_by_context(
|
||||||
|
self, mock_timeblock, mock_participant, mock_contenttype
|
||||||
|
):
|
||||||
|
"""Test that non-business hours blocks work normally regardless of context."""
|
||||||
|
# Arrange
|
||||||
|
mock_resource = Mock()
|
||||||
|
mock_resource.max_concurrent_events = 10
|
||||||
|
mock_resource.buffer_duration = timedelta(minutes=0)
|
||||||
|
mock_resource.name = "Room A"
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
|
end = start + timedelta(hours=1)
|
||||||
|
|
||||||
|
# Mock regular HARD block (not business hours)
|
||||||
|
mock_block = Mock()
|
||||||
|
mock_block.block_type = 'HARD'
|
||||||
|
mock_block.title = "Maintenance"
|
||||||
|
mock_block.resource = mock_resource
|
||||||
|
mock_block.blocks_datetime_range.return_value = True
|
||||||
|
mock_block.is_business_hours = False
|
||||||
|
mock_block.Purpose = Mock()
|
||||||
|
mock_block.Purpose.BUSINESS_HOURS = 'BUSINESS_HOURS'
|
||||||
|
mock_block.purpose = 'CLOSURE'
|
||||||
|
|
||||||
|
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
||||||
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
|
|
||||||
|
# Mock no events
|
||||||
|
mock_contenttype.objects.get_for_model.return_value = Mock(id=1)
|
||||||
|
mock_participant.objects.filter.return_value.select_related.return_value = []
|
||||||
|
|
||||||
|
# Act - Test with customer context
|
||||||
|
is_available, reason, warnings = AvailabilityService.check_availability(
|
||||||
|
mock_resource, start, end, booking_context='customer_booking'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert is_available is False
|
||||||
|
assert "maintenance" in reason.lower()
|
||||||
|
|
||||||
|
# Act - Test with staff context
|
||||||
|
is_available2, reason2, warnings2 = AvailabilityService.check_availability(
|
||||||
|
mock_resource, start, end, booking_context='staff_scheduling'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert - Same result for both contexts
|
||||||
|
assert is_available2 is False
|
||||||
|
assert "maintenance" in reason2.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestServicePrepAndTakedownTime:
|
||||||
|
"""Test service prep_time and takedown_time buffer handling."""
|
||||||
|
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.ContentType')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.Participant')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.TimeBlock')
|
||||||
|
def test_service_prep_time_used_for_conflict_check(
|
||||||
|
self, mock_timeblock, mock_participant, mock_contenttype
|
||||||
|
):
|
||||||
|
"""Test that service prep_time is used when checking for overlapping events."""
|
||||||
|
# Arrange
|
||||||
|
mock_resource = Mock()
|
||||||
|
mock_resource.max_concurrent_events = 1
|
||||||
|
mock_resource.buffer_duration = timedelta(minutes=0)
|
||||||
|
mock_resource.name = "Staff Member"
|
||||||
|
|
||||||
|
# Service with 15 minute prep time
|
||||||
|
mock_service = Mock()
|
||||||
|
mock_service.prep_time = 15
|
||||||
|
mock_service.takedown_time = 0
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
|
end = start + timedelta(hours=1)
|
||||||
|
|
||||||
|
# No time blocks
|
||||||
|
mock_timeblock.objects.filter.return_value.order_by.return_value = []
|
||||||
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
|
|
||||||
|
# Mock existing event that ends 10 minutes before our start
|
||||||
|
# Without prep_time, this would be available
|
||||||
|
# With 15 min prep_time, our window starts 15 min earlier, causing overlap
|
||||||
|
mock_event = Mock()
|
||||||
|
mock_event.id = 1
|
||||||
|
mock_event.status = 'SCHEDULED'
|
||||||
|
# Event ends 10 minutes before our service would start
|
||||||
|
mock_event.start_time = start - timedelta(minutes=70)
|
||||||
|
mock_event.end_time = start - timedelta(minutes=10)
|
||||||
|
|
||||||
|
mock_participant_obj = Mock()
|
||||||
|
mock_participant_obj.event = mock_event
|
||||||
|
|
||||||
|
mock_contenttype.objects.get_for_model.return_value = Mock(id=1)
|
||||||
|
mock_participant.objects.filter.return_value.select_related.return_value = [mock_participant_obj]
|
||||||
|
|
||||||
|
# Act - With service that has prep_time
|
||||||
|
is_available, reason, warnings = AvailabilityService.check_availability(
|
||||||
|
mock_resource, start, end, service=mock_service
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert - Should be unavailable because prep_time causes overlap
|
||||||
|
assert is_available is False
|
||||||
|
assert "capacity exceeded" in reason.lower()
|
||||||
|
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.ContentType')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.Participant')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.TimeBlock')
|
||||||
|
def test_service_takedown_time_used_for_conflict_check(
|
||||||
|
self, mock_timeblock, mock_participant, mock_contenttype
|
||||||
|
):
|
||||||
|
"""Test that service takedown_time is used when checking for overlapping events."""
|
||||||
|
# Arrange
|
||||||
|
mock_resource = Mock()
|
||||||
|
mock_resource.max_concurrent_events = 1
|
||||||
|
mock_resource.buffer_duration = timedelta(minutes=0)
|
||||||
|
mock_resource.name = "Staff Member"
|
||||||
|
|
||||||
|
# Service with 30 minute takedown time
|
||||||
|
mock_service = Mock()
|
||||||
|
mock_service.prep_time = 0
|
||||||
|
mock_service.takedown_time = 30
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
|
end = start + timedelta(hours=1)
|
||||||
|
|
||||||
|
# No time blocks
|
||||||
|
mock_timeblock.objects.filter.return_value.order_by.return_value = []
|
||||||
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
|
|
||||||
|
# Mock existing event that starts 20 minutes after our end
|
||||||
|
# Without takedown_time, this would be available
|
||||||
|
# With 30 min takedown_time, our window extends 30 min, causing overlap
|
||||||
|
mock_event = Mock()
|
||||||
|
mock_event.id = 2
|
||||||
|
mock_event.status = 'SCHEDULED'
|
||||||
|
# Event starts 20 minutes after our service ends
|
||||||
|
mock_event.start_time = end + timedelta(minutes=20)
|
||||||
|
mock_event.end_time = end + timedelta(minutes=80)
|
||||||
|
|
||||||
|
mock_participant_obj = Mock()
|
||||||
|
mock_participant_obj.event = mock_event
|
||||||
|
|
||||||
|
mock_contenttype.objects.get_for_model.return_value = Mock(id=1)
|
||||||
|
mock_participant.objects.filter.return_value.select_related.return_value = [mock_participant_obj]
|
||||||
|
|
||||||
|
# Act - With service that has takedown_time
|
||||||
|
is_available, reason, warnings = AvailabilityService.check_availability(
|
||||||
|
mock_resource, start, end, service=mock_service
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert - Should be unavailable because takedown_time causes overlap
|
||||||
|
assert is_available is False
|
||||||
|
assert "capacity exceeded" in reason.lower()
|
||||||
|
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.ContentType')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.Participant')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.TimeBlock')
|
||||||
|
def test_buffer_check_ignores_business_hours(
|
||||||
|
self, mock_timeblock, mock_participant, mock_contenttype
|
||||||
|
):
|
||||||
|
"""Test that prep/takedown windows can extend outside business hours."""
|
||||||
|
# Arrange
|
||||||
|
mock_resource = Mock()
|
||||||
|
mock_resource.max_concurrent_events = 10
|
||||||
|
mock_resource.buffer_duration = timedelta(minutes=0)
|
||||||
|
mock_resource.name = "Staff Member"
|
||||||
|
|
||||||
|
# Service with prep time that extends before business hours
|
||||||
|
mock_service = Mock()
|
||||||
|
mock_service.prep_time = 30 # 30 min prep before
|
||||||
|
mock_service.takedown_time = 0
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
|
end = start + timedelta(hours=1)
|
||||||
|
|
||||||
|
# Mock business hours block that would block times outside hours
|
||||||
|
mock_block = Mock()
|
||||||
|
mock_block.block_type = 'SOFT'
|
||||||
|
mock_block.title = "Business Hours"
|
||||||
|
mock_block.resource = None
|
||||||
|
mock_block.is_business_hours = True
|
||||||
|
# Return True for service window (within hours), False for prep window (outside)
|
||||||
|
mock_block.blocks_datetime_range.side_effect = lambda s, e: s >= start
|
||||||
|
|
||||||
|
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
||||||
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
|
|
||||||
|
# No events
|
||||||
|
mock_contenttype.objects.get_for_model.return_value = Mock(id=1)
|
||||||
|
mock_participant.objects.filter.return_value.select_related.return_value = []
|
||||||
|
|
||||||
|
# Act - Customer booking with service
|
||||||
|
is_available, reason, warnings = AvailabilityService.check_availability(
|
||||||
|
mock_resource, start, end, booking_context='customer_booking', service=mock_service
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert - Should be available because prep time can extend outside business hours
|
||||||
|
assert is_available is True
|
||||||
|
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.ContentType')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.Participant')
|
||||||
|
@patch('smoothschedule.scheduling.schedule.services.TimeBlock')
|
||||||
|
def test_buffer_check_respects_hard_blocks(
|
||||||
|
self, mock_timeblock, mock_participant, mock_contenttype
|
||||||
|
):
|
||||||
|
"""Test that prep/takedown windows are blocked by hard blocks."""
|
||||||
|
# Arrange
|
||||||
|
mock_resource = Mock()
|
||||||
|
mock_resource.max_concurrent_events = 10
|
||||||
|
mock_resource.buffer_duration = timedelta(minutes=0)
|
||||||
|
mock_resource.name = "Staff Member"
|
||||||
|
|
||||||
|
# Service with prep time
|
||||||
|
mock_service = Mock()
|
||||||
|
mock_service.prep_time = 30
|
||||||
|
mock_service.takedown_time = 0
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
|
end = start + timedelta(hours=1)
|
||||||
|
prep_start = start - timedelta(minutes=30)
|
||||||
|
|
||||||
|
# Mock hard block that covers the prep time window
|
||||||
|
mock_block = Mock()
|
||||||
|
mock_block.block_type = 'HARD'
|
||||||
|
mock_block.title = "Maintenance"
|
||||||
|
mock_block.resource = None
|
||||||
|
mock_block.is_business_hours = False
|
||||||
|
# Block the prep window but not the service window
|
||||||
|
mock_block.blocks_datetime_range.side_effect = lambda s, e: s < start
|
||||||
|
|
||||||
|
mock_timeblock.objects.filter.return_value.order_by.return_value = [mock_block]
|
||||||
|
mock_timeblock.BlockType.HARD = 'HARD'
|
||||||
|
|
||||||
|
# No events
|
||||||
|
mock_contenttype.objects.get_for_model.return_value = Mock(id=1)
|
||||||
|
mock_participant.objects.filter.return_value.select_related.return_value = []
|
||||||
|
|
||||||
|
# Act
|
||||||
|
is_available, reason, warnings = AvailabilityService.check_availability(
|
||||||
|
mock_resource, start, end, service=mock_service
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert - Should be unavailable because hard block covers prep time
|
||||||
|
assert is_available is False
|
||||||
|
assert "prep time blocked" in reason.lower()
|
||||||
|
|||||||
@@ -2229,12 +2229,21 @@ class TimeBlockViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = queryset.filter(resource__isnull=False)
|
queryset = queryset.filter(resource__isnull=False)
|
||||||
|
|
||||||
blocked_dates = []
|
blocked_dates = []
|
||||||
|
business_hours_blocks = []
|
||||||
|
|
||||||
for block in queryset:
|
for block in queryset:
|
||||||
|
# Handle BUSINESS_HOURS blocks specially - they define OPEN time, not blocked time
|
||||||
|
if block.purpose == TimeBlock.Purpose.BUSINESS_HOURS:
|
||||||
|
business_hours_blocks.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Regular blocks - add as blocked periods
|
||||||
dates = block.get_blocked_dates_in_range(start_date, end_date)
|
dates = block.get_blocked_dates_in_range(start_date, end_date)
|
||||||
for blocked_info in dates:
|
for blocked_info in dates:
|
||||||
blocked_dates.append({
|
blocked_dates.append({
|
||||||
'date': blocked_info['date'].isoformat(),
|
'date': blocked_info['date'].isoformat(),
|
||||||
'block_type': block.block_type,
|
'block_type': block.block_type,
|
||||||
|
'purpose': block.purpose,
|
||||||
'title': block.title,
|
'title': block.title,
|
||||||
'resource_id': block.resource_id,
|
'resource_id': block.resource_id,
|
||||||
'all_day': block.all_day,
|
'all_day': block.all_day,
|
||||||
@@ -2243,6 +2252,66 @@ class TimeBlockViewSet(viewsets.ModelViewSet):
|
|||||||
'time_block_id': block.id,
|
'time_block_id': block.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# For BUSINESS_HOURS blocks, generate INVERSE blocked periods
|
||||||
|
# (times OUTSIDE business hours should be shown as blocked)
|
||||||
|
from datetime import time as dt_time, timedelta
|
||||||
|
if business_hours_blocks:
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
# Find business hours for this date
|
||||||
|
day_business_hours = None
|
||||||
|
for bh_block in business_hours_blocks:
|
||||||
|
if bh_block.blocks_date(current_date):
|
||||||
|
if not bh_block.all_day and bh_block.start_time and bh_block.end_time:
|
||||||
|
day_business_hours = (bh_block.start_time, bh_block.end_time, bh_block)
|
||||||
|
break
|
||||||
|
|
||||||
|
if day_business_hours:
|
||||||
|
bh_start, bh_end, bh_block = day_business_hours
|
||||||
|
# Add "before business hours" blocked period (midnight to business start)
|
||||||
|
if bh_start > dt_time(0, 0):
|
||||||
|
blocked_dates.append({
|
||||||
|
'date': current_date.isoformat(),
|
||||||
|
'block_type': 'HARD',
|
||||||
|
'purpose': 'OUTSIDE_BUSINESS_HOURS',
|
||||||
|
'title': 'Before Business Hours',
|
||||||
|
'resource_id': None,
|
||||||
|
'all_day': False,
|
||||||
|
'start_time': '00:00:00',
|
||||||
|
'end_time': bh_start.isoformat(),
|
||||||
|
'time_block_id': bh_block.id,
|
||||||
|
})
|
||||||
|
# Add "after business hours" blocked period (business end to midnight)
|
||||||
|
if bh_end < dt_time(23, 59):
|
||||||
|
blocked_dates.append({
|
||||||
|
'date': current_date.isoformat(),
|
||||||
|
'block_type': 'HARD',
|
||||||
|
'purpose': 'OUTSIDE_BUSINESS_HOURS',
|
||||||
|
'title': 'After Business Hours',
|
||||||
|
'resource_id': None,
|
||||||
|
'all_day': False,
|
||||||
|
'start_time': bh_end.isoformat(),
|
||||||
|
'end_time': '23:59:59',
|
||||||
|
'time_block_id': bh_block.id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# No business hours for this day - check if any BUSINESS_HOURS blocks exist
|
||||||
|
# If blocks exist but none match this day, business is CLOSED
|
||||||
|
if business_hours_blocks:
|
||||||
|
blocked_dates.append({
|
||||||
|
'date': current_date.isoformat(),
|
||||||
|
'block_type': 'HARD',
|
||||||
|
'purpose': 'BUSINESS_CLOSED',
|
||||||
|
'title': 'Business Closed',
|
||||||
|
'resource_id': None,
|
||||||
|
'all_day': True,
|
||||||
|
'start_time': None,
|
||||||
|
'end_time': None,
|
||||||
|
'time_block_id': business_hours_blocks[0].id,
|
||||||
|
})
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
# Sort by date
|
# Sort by date
|
||||||
blocked_dates.sort(key=lambda d: (d['date'], d['resource_id'] or 0))
|
blocked_dates.sort(key=lambda d: (d['date'], d['resource_id'] or 0))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user