@bailierich/booking-components 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -0
- package/TENANT_DATA_INTEGRATION.md +402 -0
- package/TENANT_SETUP.md +316 -0
- package/components/BookingFlow/BookingFlow.tsx +790 -0
- package/components/BookingFlow/index.ts +5 -0
- package/components/BookingFlow/steps/AddonsSelection.tsx +118 -0
- package/components/BookingFlow/steps/Confirmation.tsx +185 -0
- package/components/BookingFlow/steps/ContactForm.tsx +292 -0
- package/components/BookingFlow/steps/CycleAwareDateSelection.tsx +277 -0
- package/components/BookingFlow/steps/DateSelection.tsx +473 -0
- package/components/BookingFlow/steps/ServiceSelection.tsx +315 -0
- package/components/BookingFlow/steps/TimeSelection.tsx +230 -0
- package/components/BookingFlow/steps/index.ts +10 -0
- package/components/BottomSheet/index.tsx +120 -0
- package/components/Forms/FormBlock.tsx +283 -0
- package/components/Forms/FormField.tsx +385 -0
- package/components/Forms/FormRenderer.tsx +216 -0
- package/components/Forms/FormValidation.ts +122 -0
- package/components/Forms/index.ts +4 -0
- package/components/HoldTimer/HoldTimer.tsx +266 -0
- package/components/HoldTimer/index.ts +2 -0
- package/components/SectionRenderer.tsx +558 -0
- package/components/Sections/About.tsx +145 -0
- package/components/Sections/BeforeAfter.tsx +81 -0
- package/components/Sections/BookingSection.tsx +76 -0
- package/components/Sections/Contact.tsx +103 -0
- package/components/Sections/FAQSection.tsx +239 -0
- package/components/Sections/FeatureContent.tsx +113 -0
- package/components/Sections/FeaturedLink.tsx +103 -0
- package/components/Sections/FixedInfoCard.tsx +189 -0
- package/components/Sections/Gallery.tsx +83 -0
- package/components/Sections/Header.tsx +78 -0
- package/components/Sections/Hero.tsx +178 -0
- package/components/Sections/ImageSection.tsx +147 -0
- package/components/Sections/InstagramFeed.tsx +38 -0
- package/components/Sections/LinkList.tsx +76 -0
- package/components/Sections/LocationMap.tsx +202 -0
- package/components/Sections/Logo.tsx +61 -0
- package/components/Sections/MinimalFooter.tsx +78 -0
- package/components/Sections/MinimalHeader.tsx +81 -0
- package/components/Sections/MinimalNavigation.tsx +63 -0
- package/components/Sections/Navbar.tsx +258 -0
- package/components/Sections/PricingTable.tsx +106 -0
- package/components/Sections/ScrollingTextDivider.tsx +138 -0
- package/components/Sections/ScrollingTextDivider.tsx.bak +138 -0
- package/components/Sections/ServicesPreview.tsx +129 -0
- package/components/Sections/SocialBar.tsx +177 -0
- package/components/Sections/Team.tsx +80 -0
- package/components/Sections/Testimonials.tsx +92 -0
- package/components/Sections/TextSection.tsx +116 -0
- package/components/Sections/VideoSection.tsx +178 -0
- package/components/Sections/index.ts +57 -0
- package/components/index.ts +21 -0
- package/dist/index-DAai7Glf.d.mts +474 -0
- package/dist/index-DAai7Glf.d.ts +474 -0
- package/dist/index.d.mts +1075 -0
- package/dist/index.d.ts +1075 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +22 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles/index.d.mts +1 -0
- package/dist/styles/index.d.ts +1 -0
- package/dist/styles/index.js +2 -0
- package/dist/styles/index.js.map +1 -0
- package/dist/styles/index.mjs +2 -0
- package/dist/styles/index.mjs.map +1 -0
- package/docs/API.md +849 -0
- package/docs/CALLBACKS.md +760 -0
- package/docs/COMPLETE_SESSION_SUMMARY.md +404 -0
- package/docs/DATA_SHAPES.md +684 -0
- package/docs/MIGRATION.md +662 -0
- package/docs/PAYMENT_INTEGRATION.md +766 -0
- package/docs/SESSION_SUMMARY.md +185 -0
- package/docs/STYLING.md +735 -0
- package/index.ts +4 -0
- package/lib/storage.ts +239 -0
- package/package.json +59 -0
- package/styles/animations.ts +210 -0
- package/styles/index.ts +1 -0
- package/tsconfig.json +32 -0
- package/tsup.config.ts +13 -0
- package/types/index.ts +369 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
5
|
+
import {
|
|
6
|
+
ServiceSelection,
|
|
7
|
+
DateSelection,
|
|
8
|
+
TimeSelection,
|
|
9
|
+
AddonsSelection,
|
|
10
|
+
ContactForm,
|
|
11
|
+
Confirmation
|
|
12
|
+
} from './steps';
|
|
13
|
+
import { HoldTimer } from '../HoldTimer';
|
|
14
|
+
import type {
|
|
15
|
+
HoldTimerConfig,
|
|
16
|
+
HoldStatus,
|
|
17
|
+
CreateHoldFn,
|
|
18
|
+
ExtendHoldFn,
|
|
19
|
+
ReleaseHoldFn
|
|
20
|
+
} from '../../types';
|
|
21
|
+
|
|
22
|
+
// Local interface for step management
|
|
23
|
+
interface BookingFlowStep {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
settings?: any;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Export simplified versions (not conflicting with types/index.ts)
|
|
31
|
+
export interface BookingFlowConfig {
|
|
32
|
+
steps: {
|
|
33
|
+
service_selection?: {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
settings?: any;
|
|
36
|
+
};
|
|
37
|
+
date_selection?: {
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
settings?: any;
|
|
40
|
+
};
|
|
41
|
+
time_selection?: {
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
settings?: any;
|
|
44
|
+
};
|
|
45
|
+
addon_selection?: {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
settings?: any;
|
|
48
|
+
};
|
|
49
|
+
contact_form?: {
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
settings?: any;
|
|
52
|
+
};
|
|
53
|
+
confirmation?: {
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
settings?: any;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
progressBar?: {
|
|
59
|
+
style?: 'dots' | 'bar' | 'minimal';
|
|
60
|
+
};
|
|
61
|
+
transitions?: {
|
|
62
|
+
style?: 'slide' | 'fade';
|
|
63
|
+
speed?: 'fast' | 'normal' | 'slow';
|
|
64
|
+
};
|
|
65
|
+
background?: {
|
|
66
|
+
type: 'color' | 'gradient' | 'image';
|
|
67
|
+
value: string;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BookingFlowProps {
|
|
72
|
+
config: BookingFlowConfig;
|
|
73
|
+
colors: {
|
|
74
|
+
primary: string;
|
|
75
|
+
secondary: string;
|
|
76
|
+
text?: string;
|
|
77
|
+
bookingText?: string;
|
|
78
|
+
};
|
|
79
|
+
// Data providers - pass your service/date/time data
|
|
80
|
+
services?: any[];
|
|
81
|
+
categories?: any[];
|
|
82
|
+
dates?: string[];
|
|
83
|
+
times?: string[];
|
|
84
|
+
addons?: any[];
|
|
85
|
+
// Payment configuration (for confirmation step)
|
|
86
|
+
paymentProvider?: 'square' | 'stripe';
|
|
87
|
+
paymentConfig?: any;
|
|
88
|
+
isPreview?: boolean;
|
|
89
|
+
// Callbacks
|
|
90
|
+
onComplete?: (bookingData: any) => void;
|
|
91
|
+
onStepChange?: (stepIndex: number, stepId: string) => void;
|
|
92
|
+
onServiceSelect?: (serviceId: string) => void;
|
|
93
|
+
onDateSelect?: (date: string) => void;
|
|
94
|
+
onContactInfoChange?: (contactInfo: any) => void; // NEW: Callback when contact info changes
|
|
95
|
+
// Optional: provide custom step validation
|
|
96
|
+
validateStep?: (stepId: string, data: any) => boolean;
|
|
97
|
+
// Hold timer integration (optional)
|
|
98
|
+
holdTimer?: {
|
|
99
|
+
enabled: boolean;
|
|
100
|
+
config: Partial<HoldTimerConfig>;
|
|
101
|
+
createHold: CreateHoldFn;
|
|
102
|
+
extendHold: ExtendHoldFn;
|
|
103
|
+
releaseHold: ReleaseHoldFn;
|
|
104
|
+
};
|
|
105
|
+
// Optional: custom step order for mod-based flows
|
|
106
|
+
stepOrder?: string[]; // NEW: Array of step IDs in desired order, e.g., ['details', 'service', 'date', 'time', 'addons', 'confirm']
|
|
107
|
+
// Optional: opt-in modules for contact form
|
|
108
|
+
optInModules?: import('../../types').OptInModule[]; // NEW: Modules for cycle-aware, etc.
|
|
109
|
+
onOptInData?: (moduleId: string, data: any) => void; // NEW: Callback for opt-in module data
|
|
110
|
+
// Optional: custom payment form component to replace default confirmation
|
|
111
|
+
customPaymentForm?: React.ComponentType<any>;
|
|
112
|
+
// Optional: cycle phase data for date selection
|
|
113
|
+
cyclePhases?: Record<string, string>; // NEW: Map of date -> phase for cycle-aware booking
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* BookingFlow - Main orchestrator component for multi-step booking
|
|
118
|
+
*
|
|
119
|
+
* Manages flow state, navigation, and renders appropriate step components.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```tsx
|
|
123
|
+
* <BookingFlow
|
|
124
|
+
* config={{
|
|
125
|
+
* steps: {
|
|
126
|
+
* service_selection: { enabled: true, settings: {} },
|
|
127
|
+
* date_selection: { enabled: true, settings: {} },
|
|
128
|
+
* time_selection: { enabled: true, settings: {} },
|
|
129
|
+
* contact_form: { enabled: true, settings: {} }
|
|
130
|
+
* },
|
|
131
|
+
* progressBar: { style: 'dots' },
|
|
132
|
+
* transitions: { style: 'slide', speed: 'normal' }
|
|
133
|
+
* }}
|
|
134
|
+
* colors={{ primary: '#BCB4FF', secondary: '#CAC426' }}
|
|
135
|
+
* services={services}
|
|
136
|
+
* onComplete={(data) => console.log('Booking complete!', data)}
|
|
137
|
+
* />
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export function BookingFlow({
|
|
141
|
+
config,
|
|
142
|
+
colors,
|
|
143
|
+
services = [],
|
|
144
|
+
categories = [],
|
|
145
|
+
dates = [],
|
|
146
|
+
times = [],
|
|
147
|
+
addons = [],
|
|
148
|
+
paymentProvider,
|
|
149
|
+
paymentConfig,
|
|
150
|
+
isPreview = false,
|
|
151
|
+
onComplete,
|
|
152
|
+
onStepChange,
|
|
153
|
+
onServiceSelect,
|
|
154
|
+
onDateSelect,
|
|
155
|
+
onContactInfoChange,
|
|
156
|
+
validateStep,
|
|
157
|
+
holdTimer,
|
|
158
|
+
stepOrder,
|
|
159
|
+
optInModules = [],
|
|
160
|
+
onOptInData,
|
|
161
|
+
customPaymentForm,
|
|
162
|
+
cyclePhases
|
|
163
|
+
}: BookingFlowProps) {
|
|
164
|
+
// Flow state
|
|
165
|
+
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
|
166
|
+
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
|
167
|
+
|
|
168
|
+
// Booking data state
|
|
169
|
+
const [selectedService, setSelectedService] = useState<string | null>(null);
|
|
170
|
+
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
|
171
|
+
const [selectedTime, setSelectedTime] = useState<string | null>(null);
|
|
172
|
+
const [selectedAddons, setSelectedAddons] = useState<string[]>([]);
|
|
173
|
+
const [contactInfo, setContactInfo] = useState<any>({});
|
|
174
|
+
const [optInData, setOptInData] = useState<any>({});
|
|
175
|
+
|
|
176
|
+
// Hold timer state
|
|
177
|
+
const [activeHold, setActiveHold] = useState<HoldStatus | null>(null);
|
|
178
|
+
|
|
179
|
+
// Configuration
|
|
180
|
+
const textColor = colors.bookingText || colors.text || '#000000';
|
|
181
|
+
const transitions = config.transitions || {};
|
|
182
|
+
const transitionStyle = transitions.style || 'slide';
|
|
183
|
+
const transitionSpeed = transitions.speed || 'normal';
|
|
184
|
+
const reducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
185
|
+
const background = config.background || { type: 'color', value: '#FFFFFF' };
|
|
186
|
+
|
|
187
|
+
// Build enabled steps array
|
|
188
|
+
const stepsMap: Record<string, BookingFlowStep> = {
|
|
189
|
+
service: {
|
|
190
|
+
id: 'service',
|
|
191
|
+
name: 'Service',
|
|
192
|
+
enabled: config.steps?.service_selection?.enabled !== false, // Core step - defaults to enabled
|
|
193
|
+
settings: config.steps?.service_selection?.settings || {}
|
|
194
|
+
},
|
|
195
|
+
date: {
|
|
196
|
+
id: 'date',
|
|
197
|
+
name: 'Date',
|
|
198
|
+
enabled: config.steps?.date_selection?.enabled !== false, // Core step - defaults to enabled
|
|
199
|
+
settings: config.steps?.date_selection?.settings || {}
|
|
200
|
+
},
|
|
201
|
+
time: {
|
|
202
|
+
id: 'time',
|
|
203
|
+
name: 'Time',
|
|
204
|
+
enabled: config.steps?.time_selection?.enabled !== false, // Core step - defaults to enabled
|
|
205
|
+
settings: config.steps?.time_selection?.settings || {}
|
|
206
|
+
},
|
|
207
|
+
addons: {
|
|
208
|
+
id: 'addons',
|
|
209
|
+
name: 'Add-ons',
|
|
210
|
+
enabled: config.steps?.addon_selection?.enabled === true, // Optional step - must be explicitly enabled
|
|
211
|
+
settings: config.steps?.addon_selection?.settings || {}
|
|
212
|
+
},
|
|
213
|
+
details: {
|
|
214
|
+
id: 'details',
|
|
215
|
+
name: 'Details',
|
|
216
|
+
enabled: config.steps?.contact_form?.enabled !== false, // Core step - defaults to enabled
|
|
217
|
+
settings: config.steps?.contact_form?.settings || {}
|
|
218
|
+
},
|
|
219
|
+
confirm: {
|
|
220
|
+
id: 'confirm',
|
|
221
|
+
name: 'Confirm',
|
|
222
|
+
enabled: config.steps?.confirmation?.enabled === true, // Conditional based on config
|
|
223
|
+
settings: config.steps?.confirmation?.settings || {}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// NEW: Use custom step order if provided, otherwise use default order
|
|
228
|
+
const defaultOrder = ['service', 'date', 'time', 'addons', 'details', 'confirm'];
|
|
229
|
+
const orderedStepIds = stepOrder || defaultOrder;
|
|
230
|
+
|
|
231
|
+
// Build steps array respecting the custom order and filtering disabled steps
|
|
232
|
+
const allSteps: BookingFlowStep[] = orderedStepIds
|
|
233
|
+
.map(id => stepsMap[id])
|
|
234
|
+
.filter(step => step && step.enabled);
|
|
235
|
+
|
|
236
|
+
console.log('[BookingFlow] Step configuration:', {
|
|
237
|
+
customStepOrder: stepOrder,
|
|
238
|
+
orderedStepIds,
|
|
239
|
+
allSteps: allSteps.map(s => s.id),
|
|
240
|
+
currentStepIndex,
|
|
241
|
+
currentStepId: allSteps[currentStepIndex]?.id
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const currentStep = allSteps[currentStepIndex];
|
|
245
|
+
|
|
246
|
+
// Background styling
|
|
247
|
+
const getBackgroundStyle = () => {
|
|
248
|
+
if (background.type === 'gradient') {
|
|
249
|
+
return { background: background.value };
|
|
250
|
+
}
|
|
251
|
+
if (background.type === 'image') {
|
|
252
|
+
return {
|
|
253
|
+
backgroundImage: `url(${background.value})`,
|
|
254
|
+
backgroundSize: 'cover',
|
|
255
|
+
backgroundPosition: 'center'
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return { backgroundColor: background.value };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Animation configuration
|
|
262
|
+
const getDuration = () => {
|
|
263
|
+
if (reducedMotion) return 0.15;
|
|
264
|
+
switch (transitionSpeed) {
|
|
265
|
+
case 'fast': return 0.2;
|
|
266
|
+
case 'slow': return 0.5;
|
|
267
|
+
default: return 0.3;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const getVariants = () => {
|
|
272
|
+
const slideDistance = 50;
|
|
273
|
+
|
|
274
|
+
if (transitionStyle === 'fade') {
|
|
275
|
+
return {
|
|
276
|
+
enter: () => ({ opacity: 0, scale: 0.95 }),
|
|
277
|
+
center: { opacity: 1, scale: 1 },
|
|
278
|
+
exit: () => ({ opacity: 0, scale: 0.95 })
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Default: slide
|
|
283
|
+
return {
|
|
284
|
+
enter: () => ({
|
|
285
|
+
x: direction === 'forward' ? slideDistance : -slideDistance,
|
|
286
|
+
opacity: 0
|
|
287
|
+
}),
|
|
288
|
+
center: { x: 0, opacity: 1 },
|
|
289
|
+
exit: () => ({
|
|
290
|
+
x: direction === 'forward' ? -slideDistance : slideDistance,
|
|
291
|
+
opacity: 0
|
|
292
|
+
})
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Step validation
|
|
297
|
+
const canGoNext = () => {
|
|
298
|
+
const stepId = currentStep?.id;
|
|
299
|
+
|
|
300
|
+
// Custom validation if provided
|
|
301
|
+
if (validateStep) {
|
|
302
|
+
const currentData = {
|
|
303
|
+
service: selectedService,
|
|
304
|
+
date: selectedDate,
|
|
305
|
+
time: selectedTime,
|
|
306
|
+
addons: selectedAddons,
|
|
307
|
+
contact: contactInfo
|
|
308
|
+
};
|
|
309
|
+
return validateStep(stepId, currentData);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Default validation
|
|
313
|
+
switch (stepId) {
|
|
314
|
+
case 'service':
|
|
315
|
+
return selectedService !== null;
|
|
316
|
+
case 'date':
|
|
317
|
+
return selectedDate !== null;
|
|
318
|
+
case 'time':
|
|
319
|
+
return selectedTime !== null;
|
|
320
|
+
case 'details':
|
|
321
|
+
// Validate contact form: name, email, and phone are required (notes optional)
|
|
322
|
+
if (isPreview) return true; // Skip validation in preview mode
|
|
323
|
+
|
|
324
|
+
const hasName = contactInfo?.name && contactInfo.name.trim().length >= 2;
|
|
325
|
+
const hasValidEmail = contactInfo?.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contactInfo.email);
|
|
326
|
+
const hasValidPhone = contactInfo?.phone && contactInfo.phone.replace(/\D/g, '').length >= 10;
|
|
327
|
+
|
|
328
|
+
return hasName && hasValidEmail && hasValidPhone;
|
|
329
|
+
default:
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const isStepCompleted = (stepIndex: number) => {
|
|
335
|
+
return stepIndex < currentStepIndex;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Navigation
|
|
339
|
+
const handleStepChange = (newStepIndex: number) => {
|
|
340
|
+
setDirection(newStepIndex > currentStepIndex ? 'forward' : 'backward');
|
|
341
|
+
setCurrentStepIndex(newStepIndex);
|
|
342
|
+
|
|
343
|
+
if (onStepChange) {
|
|
344
|
+
onStepChange(newStepIndex, allSteps[newStepIndex]?.id);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const handleNext = () => {
|
|
349
|
+
if (currentStepIndex < allSteps.length - 1) {
|
|
350
|
+
handleStepChange(currentStepIndex + 1);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const handleBack = async () => {
|
|
355
|
+
if (currentStepIndex === 0) return;
|
|
356
|
+
|
|
357
|
+
const prevStepIndex = currentStepIndex - 1;
|
|
358
|
+
const prevStep = allSteps[prevStepIndex];
|
|
359
|
+
|
|
360
|
+
// If going back to/before time selection, release hold
|
|
361
|
+
if (prevStep?.id === 'time' && activeHold && holdTimer?.releaseHold) {
|
|
362
|
+
try {
|
|
363
|
+
await holdTimer.releaseHold(activeHold.holdId);
|
|
364
|
+
setActiveHold(null);
|
|
365
|
+
setSelectedTime(null);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
console.error('[BookingFlow] Failed to release hold:', error);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
handleStepChange(prevStepIndex);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Enhanced time selection handler with hold integration
|
|
375
|
+
const handleTimeSelect = async (time: string) => {
|
|
376
|
+
setSelectedTime(time);
|
|
377
|
+
|
|
378
|
+
// If hold timer enabled, create hold before proceeding
|
|
379
|
+
if (holdTimer?.enabled && holdTimer.createHold && selectedDate && selectedService) {
|
|
380
|
+
try {
|
|
381
|
+
const selectedServiceData = services.find((s: any) => s.id === selectedService);
|
|
382
|
+
|
|
383
|
+
const hold = await holdTimer.createHold({
|
|
384
|
+
date: selectedDate,
|
|
385
|
+
time,
|
|
386
|
+
serviceId: selectedService,
|
|
387
|
+
serviceDuration: selectedServiceData?.duration || 60
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
setActiveHold(hold);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.error('[BookingFlow] Failed to create hold:', error);
|
|
393
|
+
// Don't proceed if hold creation fails
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Navigate to next step
|
|
399
|
+
handleNext();
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Hold expiration handler
|
|
403
|
+
const handleHoldExpired = () => {
|
|
404
|
+
setActiveHold(null);
|
|
405
|
+
setSelectedTime(null);
|
|
406
|
+
|
|
407
|
+
// Find and navigate to time step
|
|
408
|
+
const timeStepIndex = allSteps.findIndex(s => s.id === 'time');
|
|
409
|
+
if (timeStepIndex >= 0) {
|
|
410
|
+
handleStepChange(timeStepIndex);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const handleComplete = async () => {
|
|
415
|
+
const bookingData = {
|
|
416
|
+
service: selectedService,
|
|
417
|
+
date: selectedDate,
|
|
418
|
+
time: selectedTime,
|
|
419
|
+
addons: selectedAddons,
|
|
420
|
+
contact: contactInfo,
|
|
421
|
+
optIn: optInData
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
if (onComplete) {
|
|
425
|
+
onComplete(bookingData);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Render current step
|
|
430
|
+
const renderCurrentStep = () => {
|
|
431
|
+
if (!currentStep) return null;
|
|
432
|
+
|
|
433
|
+
const stepProps = {
|
|
434
|
+
settings: currentStep.settings,
|
|
435
|
+
colors
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// Get selected service data once for all steps that need it
|
|
439
|
+
const selectedServiceData = services.find((s: any) => s.id === selectedService);
|
|
440
|
+
|
|
441
|
+
switch (currentStep.id) {
|
|
442
|
+
case 'service':
|
|
443
|
+
return (
|
|
444
|
+
<ServiceSelection
|
|
445
|
+
{...stepProps}
|
|
446
|
+
services={services}
|
|
447
|
+
categories={categories}
|
|
448
|
+
selectedService={selectedService}
|
|
449
|
+
onServiceSelect={(serviceId) => {
|
|
450
|
+
setSelectedService(serviceId);
|
|
451
|
+
// NEW: Notify parent of service selection
|
|
452
|
+
if (onServiceSelect) {
|
|
453
|
+
onServiceSelect(serviceId);
|
|
454
|
+
}
|
|
455
|
+
}}
|
|
456
|
+
/>
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
case 'date':
|
|
460
|
+
return (
|
|
461
|
+
<DateSelection
|
|
462
|
+
{...stepProps}
|
|
463
|
+
availableDates={dates}
|
|
464
|
+
selectedDate={selectedDate}
|
|
465
|
+
onDateSelect={(date) => {
|
|
466
|
+
setSelectedDate(date);
|
|
467
|
+
// NEW: Notify parent of date selection
|
|
468
|
+
if (onDateSelect) {
|
|
469
|
+
onDateSelect(date);
|
|
470
|
+
}
|
|
471
|
+
}}
|
|
472
|
+
cyclePhases={cyclePhases} // NEW: Pass cycle phases for cycle-aware booking
|
|
473
|
+
selectedService={selectedServiceData} // NEW: Pass selected service for summary card
|
|
474
|
+
addons={addons} // NEW: Pass addons for selection
|
|
475
|
+
selectedAddons={selectedAddons} // NEW: Pass selected addons
|
|
476
|
+
onAddonsChange={setSelectedAddons} // NEW: Handle addon changes
|
|
477
|
+
addonPlacement={config.steps?.addon_selection?.settings?.placement || 'date_selection'} // NEW: Pass addon placement config
|
|
478
|
+
/>
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
case 'time':
|
|
482
|
+
return (
|
|
483
|
+
<TimeSelection
|
|
484
|
+
{...stepProps}
|
|
485
|
+
availableTimes={times}
|
|
486
|
+
selectedTime={selectedTime}
|
|
487
|
+
onTimeSelect={handleTimeSelect}
|
|
488
|
+
serviceDuration={selectedServiceData?.duration || 60}
|
|
489
|
+
selectedService={selectedServiceData}
|
|
490
|
+
selectedDate={selectedDate}
|
|
491
|
+
selectedAddons={selectedAddons}
|
|
492
|
+
addons={addons}
|
|
493
|
+
/>
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
case 'addons':
|
|
497
|
+
return (
|
|
498
|
+
<AddonsSelection
|
|
499
|
+
{...stepProps}
|
|
500
|
+
addons={addons}
|
|
501
|
+
selectedAddons={selectedAddons}
|
|
502
|
+
onAddonsChange={setSelectedAddons}
|
|
503
|
+
/>
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
case 'details':
|
|
507
|
+
return (
|
|
508
|
+
<ContactForm
|
|
509
|
+
{...stepProps}
|
|
510
|
+
contactInfo={contactInfo}
|
|
511
|
+
onContactInfoChange={(info) => {
|
|
512
|
+
setContactInfo(info);
|
|
513
|
+
// NEW: Notify parent of contact info changes
|
|
514
|
+
if (onContactInfoChange) {
|
|
515
|
+
onContactInfoChange(info);
|
|
516
|
+
}
|
|
517
|
+
}}
|
|
518
|
+
optInModules={optInModules} // NEW: Pass opt-in modules from parent
|
|
519
|
+
onOptInData={onOptInData} // NEW: Pass opt-in data callback
|
|
520
|
+
/>
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
case 'confirm':
|
|
524
|
+
const selectedAddonsData = addons.filter((a: any) => selectedAddons.includes(a.id));
|
|
525
|
+
|
|
526
|
+
// Calculate addon total cost
|
|
527
|
+
const addonTotalCost = selectedAddonsData.reduce((sum, addon) => {
|
|
528
|
+
return sum + (addon.addonPrice || addon.price || 0);
|
|
529
|
+
}, 0);
|
|
530
|
+
|
|
531
|
+
// Calculate total amount (service + addons)
|
|
532
|
+
const totalAmount = (selectedServiceData?.price || 0) + addonTotalCost;
|
|
533
|
+
|
|
534
|
+
// Calculate total duration (service + addons that affect duration)
|
|
535
|
+
const totalDuration = selectedAddonsData.reduce((duration, addon) => {
|
|
536
|
+
if (addon.affectsDuration && addon.additionalDuration) {
|
|
537
|
+
return duration + addon.additionalDuration;
|
|
538
|
+
}
|
|
539
|
+
return duration;
|
|
540
|
+
}, selectedServiceData?.duration || 60);
|
|
541
|
+
|
|
542
|
+
// Use custom payment form if provided, otherwise use default Confirmation
|
|
543
|
+
if (customPaymentForm) {
|
|
544
|
+
const CustomPaymentForm = customPaymentForm;
|
|
545
|
+
return (
|
|
546
|
+
<CustomPaymentForm
|
|
547
|
+
bookingData={{
|
|
548
|
+
service: selectedService,
|
|
549
|
+
serviceName: selectedServiceData?.name,
|
|
550
|
+
date: selectedDate,
|
|
551
|
+
time: selectedTime,
|
|
552
|
+
addons: selectedAddons,
|
|
553
|
+
contact: contactInfo,
|
|
554
|
+
amount: selectedServiceData?.price || 0,
|
|
555
|
+
duration: totalDuration,
|
|
556
|
+
// Contact info for automation
|
|
557
|
+
clientName: contactInfo?.name,
|
|
558
|
+
clientEmail: contactInfo?.email,
|
|
559
|
+
clientPhone: contactInfo?.phone,
|
|
560
|
+
// Add pendingBooking with COMPLETE booking data for PaymentForm
|
|
561
|
+
pendingBooking: {
|
|
562
|
+
// Customer info
|
|
563
|
+
customerClientId: null, // Will be populated by wrapper if returning customer
|
|
564
|
+
|
|
565
|
+
// Service info
|
|
566
|
+
serviceId: selectedService,
|
|
567
|
+
date: selectedDate,
|
|
568
|
+
time: selectedTime,
|
|
569
|
+
notes: contactInfo?.notes || '',
|
|
570
|
+
|
|
571
|
+
// Addon info
|
|
572
|
+
selectedAddons: selectedAddonsData, // Full addon objects for display
|
|
573
|
+
addonServices: selectedAddons, // Addon IDs for database
|
|
574
|
+
addonTotalCost: addonTotalCost,
|
|
575
|
+
|
|
576
|
+
// Payment calculations
|
|
577
|
+
totalAmount: totalAmount,
|
|
578
|
+
duration: totalDuration
|
|
579
|
+
}
|
|
580
|
+
}}
|
|
581
|
+
onPaymentSuccess={handleComplete}
|
|
582
|
+
onPaymentError={(error: any) => {
|
|
583
|
+
console.error('[BookingFlow] Payment error:', error);
|
|
584
|
+
}}
|
|
585
|
+
/>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return (
|
|
590
|
+
<Confirmation
|
|
591
|
+
{...stepProps}
|
|
592
|
+
service={selectedServiceData}
|
|
593
|
+
addons={selectedAddonsData}
|
|
594
|
+
selectedDate={selectedDate || ''}
|
|
595
|
+
selectedTime={selectedTime || ''}
|
|
596
|
+
paymentProvider={paymentProvider || 'square'}
|
|
597
|
+
paymentConfig={paymentConfig || { depositPercentage: 20 }}
|
|
598
|
+
isPreview={isPreview}
|
|
599
|
+
onConfirm={handleComplete}
|
|
600
|
+
/>
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
default:
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// Render progress indicator
|
|
609
|
+
const renderProgressBar = () => {
|
|
610
|
+
const progressStyle = config.progressBar?.style || 'dots';
|
|
611
|
+
|
|
612
|
+
if (progressStyle === 'bar') {
|
|
613
|
+
return (
|
|
614
|
+
<div className="mb-6">
|
|
615
|
+
<div className="flex justify-between items-center mb-2">
|
|
616
|
+
<span className="text-sm font-medium">Step {currentStepIndex + 1} of {allSteps.length}</span>
|
|
617
|
+
<span className="text-xs opacity-60">{currentStep?.name}</span>
|
|
618
|
+
</div>
|
|
619
|
+
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
620
|
+
<motion.div
|
|
621
|
+
className="h-full rounded-full"
|
|
622
|
+
style={{ backgroundColor: colors.primary }}
|
|
623
|
+
initial={{ width: 0 }}
|
|
624
|
+
animate={{ width: `${((currentStepIndex + 1) / allSteps.length) * 100}%` }}
|
|
625
|
+
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
626
|
+
/>
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (progressStyle === 'minimal') {
|
|
633
|
+
return (
|
|
634
|
+
<div className="mb-6 text-center">
|
|
635
|
+
<p className="text-sm font-medium" style={{ color: colors.primary }}>
|
|
636
|
+
{currentStep?.name}
|
|
637
|
+
</p>
|
|
638
|
+
<p className="text-xs opacity-60 mt-1">
|
|
639
|
+
Step {currentStepIndex + 1} of {allSteps.length}
|
|
640
|
+
</p>
|
|
641
|
+
</div>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Default: dots
|
|
646
|
+
return (
|
|
647
|
+
<div className="flex justify-center mb-4 overflow-x-auto py-2">
|
|
648
|
+
<div className="flex items-center space-x-1.5 px-4 min-w-max">
|
|
649
|
+
{allSteps.map((step, index) => (
|
|
650
|
+
<div key={step.id} className="flex items-center">
|
|
651
|
+
<motion.div
|
|
652
|
+
className={`w-6 h-6 rounded-full flex items-center justify-center font-medium text-xs transition-all flex-shrink-0 ${
|
|
653
|
+
index === currentStepIndex
|
|
654
|
+
? 'text-white shadow-md'
|
|
655
|
+
: isStepCompleted(index)
|
|
656
|
+
? 'text-white'
|
|
657
|
+
: 'bg-gray-200 text-gray-500'
|
|
658
|
+
}`}
|
|
659
|
+
style={{
|
|
660
|
+
backgroundColor: index <= currentStepIndex ? colors.primary : undefined
|
|
661
|
+
}}
|
|
662
|
+
animate={{
|
|
663
|
+
scale: index === currentStepIndex ? 1.05 : 1
|
|
664
|
+
}}
|
|
665
|
+
transition={{ duration: 0.2 }}
|
|
666
|
+
>
|
|
667
|
+
{isStepCompleted(index) ? '✓' : index + 1}
|
|
668
|
+
</motion.div>
|
|
669
|
+
{index < allSteps.length - 1 && (
|
|
670
|
+
<motion.div
|
|
671
|
+
className={`w-4 h-0.5 mx-1 transition-all flex-shrink-0 ${
|
|
672
|
+
index < currentStepIndex ? '' : 'bg-gray-200'
|
|
673
|
+
}`}
|
|
674
|
+
style={{
|
|
675
|
+
backgroundColor: index < currentStepIndex ? colors.primary : undefined
|
|
676
|
+
}}
|
|
677
|
+
initial={{ scaleX: 0 }}
|
|
678
|
+
animate={{ scaleX: 1 }}
|
|
679
|
+
transition={{ duration: 0.3, delay: 0.1 }}
|
|
680
|
+
/>
|
|
681
|
+
)}
|
|
682
|
+
</div>
|
|
683
|
+
))}
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
);
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
return (
|
|
690
|
+
<div
|
|
691
|
+
className="w-full max-w-4xl mx-auto p-6 rounded-2xl shadow-xl"
|
|
692
|
+
style={{ ...getBackgroundStyle(), color: textColor }}
|
|
693
|
+
>
|
|
694
|
+
{/* Progress Indicator */}
|
|
695
|
+
{renderProgressBar()}
|
|
696
|
+
|
|
697
|
+
{/* Hold Timer - Show when active and past time step */}
|
|
698
|
+
{holdTimer?.enabled && activeHold && currentStep?.id !== 'time' && (
|
|
699
|
+
<HoldTimer
|
|
700
|
+
holdStatus={activeHold}
|
|
701
|
+
config={holdTimer.config}
|
|
702
|
+
colors={colors}
|
|
703
|
+
onExtend={async () => {
|
|
704
|
+
if (holdTimer.extendHold && activeHold) {
|
|
705
|
+
try {
|
|
706
|
+
const extended = await holdTimer.extendHold(activeHold.holdId);
|
|
707
|
+
setActiveHold(extended);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
console.error('[BookingFlow] Failed to extend hold:', error);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}}
|
|
713
|
+
onRelease={async () => {
|
|
714
|
+
if (holdTimer.releaseHold && activeHold) {
|
|
715
|
+
try {
|
|
716
|
+
await holdTimer.releaseHold(activeHold.holdId);
|
|
717
|
+
setActiveHold(null);
|
|
718
|
+
setSelectedTime(null);
|
|
719
|
+
// Navigate to time step
|
|
720
|
+
const timeStepIndex = allSteps.findIndex(s => s.id === 'time');
|
|
721
|
+
if (timeStepIndex >= 0) {
|
|
722
|
+
handleStepChange(timeStepIndex);
|
|
723
|
+
}
|
|
724
|
+
} catch (error) {
|
|
725
|
+
console.error('[BookingFlow] Failed to release hold:', error);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}}
|
|
729
|
+
onExpired={handleHoldExpired}
|
|
730
|
+
className="mb-6"
|
|
731
|
+
/>
|
|
732
|
+
)}
|
|
733
|
+
|
|
734
|
+
{/* Current Step Content */}
|
|
735
|
+
<div className="mb-6 min-h-[300px] relative overflow-hidden">
|
|
736
|
+
<AnimatePresence mode="wait" custom={direction}>
|
|
737
|
+
<motion.div
|
|
738
|
+
key={currentStepIndex}
|
|
739
|
+
custom={direction}
|
|
740
|
+
variants={getVariants()}
|
|
741
|
+
initial="enter"
|
|
742
|
+
animate="center"
|
|
743
|
+
exit="exit"
|
|
744
|
+
transition={{
|
|
745
|
+
duration: getDuration(),
|
|
746
|
+
ease: [0.4, 0, 0.2, 1]
|
|
747
|
+
}}
|
|
748
|
+
>
|
|
749
|
+
{renderCurrentStep()}
|
|
750
|
+
</motion.div>
|
|
751
|
+
</AnimatePresence>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
{/* Navigation Buttons - Hide when on confirm step with custom payment form */}
|
|
755
|
+
{!(currentStep?.id === 'confirm' && customPaymentForm) && (
|
|
756
|
+
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
|
757
|
+
<button
|
|
758
|
+
onClick={handleBack}
|
|
759
|
+
disabled={currentStepIndex === 0}
|
|
760
|
+
className="px-4 py-2 rounded-lg border border-gray-300 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-all text-sm font-medium"
|
|
761
|
+
>
|
|
762
|
+
Back
|
|
763
|
+
</button>
|
|
764
|
+
|
|
765
|
+
{currentStepIndex < allSteps.length - 1 ? (
|
|
766
|
+
<button
|
|
767
|
+
onClick={handleNext}
|
|
768
|
+
disabled={!canGoNext()}
|
|
769
|
+
className="px-6 py-2 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90 transition-all text-sm font-medium shadow-md"
|
|
770
|
+
style={{ backgroundColor: colors.primary }}
|
|
771
|
+
>
|
|
772
|
+
Next
|
|
773
|
+
</button>
|
|
774
|
+
) : (
|
|
775
|
+
<button
|
|
776
|
+
onClick={handleComplete}
|
|
777
|
+
disabled={!canGoNext()}
|
|
778
|
+
className="px-6 py-2 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90 transition-all text-sm font-medium shadow-md"
|
|
779
|
+
style={{ backgroundColor: colors.primary }}
|
|
780
|
+
>
|
|
781
|
+
{currentStep?.id === 'confirm' ? 'Pay & Confirm' : 'Complete'}
|
|
782
|
+
</button>
|
|
783
|
+
)}
|
|
784
|
+
</div>
|
|
785
|
+
)}
|
|
786
|
+
</div>
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export default BookingFlow;
|