@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,315 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { motion } from 'framer-motion';
|
|
5
|
+
import { ChevronDown } from 'lucide-react';
|
|
6
|
+
import type { Service, ServiceCategory, ServiceSelectionSettings } from '../../../types';
|
|
7
|
+
import { createEntranceAnimation } from '../../../styles';
|
|
8
|
+
|
|
9
|
+
interface ServiceSelectionProps {
|
|
10
|
+
services: Service[];
|
|
11
|
+
categories?: ServiceCategory[];
|
|
12
|
+
selectedService: string | null;
|
|
13
|
+
onServiceSelect: (serviceId: string) => void;
|
|
14
|
+
settings: ServiceSelectionSettings;
|
|
15
|
+
colors: {
|
|
16
|
+
primary: string;
|
|
17
|
+
secondary: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ServiceSelection({
|
|
22
|
+
services,
|
|
23
|
+
categories = [],
|
|
24
|
+
selectedService,
|
|
25
|
+
onServiceSelect,
|
|
26
|
+
settings,
|
|
27
|
+
colors
|
|
28
|
+
}: ServiceSelectionProps) {
|
|
29
|
+
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
30
|
+
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
|
31
|
+
new Set(categories.length > 0 ? [categories[0].id] : [])
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const layout = settings.serviceLayout || 'grid';
|
|
35
|
+
const columns = settings.columns || 2;
|
|
36
|
+
const headerText = settings.headerContent?.value || 'Choose Your Service';
|
|
37
|
+
const subheaderText = settings.subheaderContent?.value || 'Select the service you\'d like to book';
|
|
38
|
+
const showPricing = settings.showPricing !== false;
|
|
39
|
+
const showDuration = settings.showDuration !== false;
|
|
40
|
+
const showDescription = settings.showDescription !== false;
|
|
41
|
+
const enableCategories = settings.enableCategories === true;
|
|
42
|
+
const categoryStyle = settings.categoryStyle || 'dropdown';
|
|
43
|
+
|
|
44
|
+
const policyImages = settings.policyImages || [];
|
|
45
|
+
const policyText = settings.policyText || null;
|
|
46
|
+
const imageSpacing = settings.imageSpacing || 'small';
|
|
47
|
+
|
|
48
|
+
const spacingMap = {
|
|
49
|
+
none: '0px',
|
|
50
|
+
small: '8px',
|
|
51
|
+
medium: '16px',
|
|
52
|
+
large: '24px'
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const borderRadius = settings.imageBorderRadius || '0px';
|
|
56
|
+
|
|
57
|
+
const gridCols = {
|
|
58
|
+
1: 'grid-cols-1',
|
|
59
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
60
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
61
|
+
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
|
62
|
+
}[columns] || 'grid-cols-2';
|
|
63
|
+
|
|
64
|
+
const filteredServices = enableCategories && selectedCategory
|
|
65
|
+
? services.filter(s => s.category === selectedCategory)
|
|
66
|
+
: services;
|
|
67
|
+
|
|
68
|
+
const renderServiceCard = (service: Service, layout: 'grid' | 'list') => {
|
|
69
|
+
const isSelected = selectedService === service.id;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
key={service.id}
|
|
74
|
+
onClick={() => onServiceSelect(service.id)}
|
|
75
|
+
className={`w-full ${layout === 'grid' ? 'h-full' : ''} p-4 rounded-lg border-2 transition-all text-left ${layout === 'grid' ? 'flex flex-col' : ''} ${
|
|
76
|
+
isSelected
|
|
77
|
+
? 'border-current shadow-lg'
|
|
78
|
+
: 'border-gray-200 hover:border-gray-300'
|
|
79
|
+
}`}
|
|
80
|
+
style={{
|
|
81
|
+
borderColor: isSelected ? colors.primary : undefined,
|
|
82
|
+
backgroundColor: isSelected ? `${colors.primary}10` : undefined
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{layout === 'grid' ? (
|
|
86
|
+
<>
|
|
87
|
+
<p className="font-semibold mb-1">{service.name}</p>
|
|
88
|
+
{showDescription && <p className="text-xs opacity-70 mb-2 flex-1">{service.description}</p>}
|
|
89
|
+
<div className="flex justify-between items-center text-sm mt-auto">
|
|
90
|
+
{showPricing && <span className="font-bold" style={{ color: colors.primary }}>${service.price}</span>}
|
|
91
|
+
{showDuration && <span className="opacity-60">{service.duration} min</span>}
|
|
92
|
+
</div>
|
|
93
|
+
</>
|
|
94
|
+
) : (
|
|
95
|
+
<div className="flex justify-between items-start">
|
|
96
|
+
<div className="flex-1">
|
|
97
|
+
<p className="font-semibold mb-1">{service.name}</p>
|
|
98
|
+
{showDescription && <p className="text-sm opacity-70">{service.description}</p>}
|
|
99
|
+
</div>
|
|
100
|
+
<div className="text-right ml-4">
|
|
101
|
+
{showPricing && <p className="font-bold" style={{ color: colors.primary }}>${service.price}</p>}
|
|
102
|
+
{showDuration && <p className="text-sm opacity-60">{service.duration} min</p>}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</button>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="space-y-4">
|
|
112
|
+
<motion.div
|
|
113
|
+
{...createEntranceAnimation(0.05)}
|
|
114
|
+
className="text-center sm:text-left"
|
|
115
|
+
>
|
|
116
|
+
<h2 className="text-xl sm:text-2xl font-bold mb-2" style={{ color: colors.primary }}>
|
|
117
|
+
{headerText}
|
|
118
|
+
</h2>
|
|
119
|
+
<p className="text-sm opacity-70">{subheaderText}</p>
|
|
120
|
+
</motion.div>
|
|
121
|
+
|
|
122
|
+
{/* Policy Images */}
|
|
123
|
+
{policyImages && policyImages.length > 0 && (
|
|
124
|
+
<div className="w-full flex flex-col" style={{ gap: spacingMap[imageSpacing as keyof typeof spacingMap] }}>
|
|
125
|
+
{policyImages.slice(0, 3).map((image, index) => (
|
|
126
|
+
<div
|
|
127
|
+
key={index}
|
|
128
|
+
className="w-full overflow-hidden"
|
|
129
|
+
style={{
|
|
130
|
+
aspectRatio: '720 / 300',
|
|
131
|
+
borderRadius: borderRadius
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<img
|
|
135
|
+
src={image.url}
|
|
136
|
+
alt={image.alt || `Policy image ${index + 1}`}
|
|
137
|
+
className="w-full h-full object-cover"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{/* Policy Text (if no images) */}
|
|
145
|
+
{(!policyImages || policyImages.length === 0) && policyText && (
|
|
146
|
+
<div
|
|
147
|
+
className="w-full p-4 rounded-lg border"
|
|
148
|
+
style={{
|
|
149
|
+
backgroundColor: `${colors.primary}10`,
|
|
150
|
+
borderColor: `${colors.primary}30`
|
|
151
|
+
}}
|
|
152
|
+
dangerouslySetInnerHTML={{ __html: policyText }}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{/* Category Filters */}
|
|
157
|
+
{enableCategories && categories.length > 0 && (
|
|
158
|
+
<>
|
|
159
|
+
{categoryStyle === 'dropdown' && (
|
|
160
|
+
<div>
|
|
161
|
+
<label className="block text-sm font-medium mb-2">Service Category</label>
|
|
162
|
+
<select
|
|
163
|
+
value={selectedCategory || 'all'}
|
|
164
|
+
onChange={(e) => setSelectedCategory(e.target.value === 'all' ? null : e.target.value)}
|
|
165
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent transition-all"
|
|
166
|
+
style={{ '--tw-ring-color': colors.primary } as any}
|
|
167
|
+
>
|
|
168
|
+
<option value="all">All Services</option>
|
|
169
|
+
{categories.map(cat => (
|
|
170
|
+
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
|
171
|
+
))}
|
|
172
|
+
</select>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{categoryStyle === 'tabs' && (
|
|
177
|
+
<div className="flex gap-2 flex-wrap">
|
|
178
|
+
<button
|
|
179
|
+
onClick={() => setSelectedCategory(null)}
|
|
180
|
+
className={`px-4 py-2 rounded-lg transition-all text-sm font-medium ${
|
|
181
|
+
selectedCategory === null ? 'text-white shadow-lg' : 'border border-gray-300 hover:border-gray-400'
|
|
182
|
+
}`}
|
|
183
|
+
style={{ backgroundColor: selectedCategory === null ? colors.primary : undefined }}
|
|
184
|
+
>
|
|
185
|
+
All Services
|
|
186
|
+
</button>
|
|
187
|
+
{categories.map(cat => (
|
|
188
|
+
<button
|
|
189
|
+
key={cat.id}
|
|
190
|
+
onClick={() => setSelectedCategory(cat.id)}
|
|
191
|
+
className={`px-4 py-2 rounded-lg transition-all text-sm font-medium ${
|
|
192
|
+
selectedCategory === cat.id ? 'text-white shadow-lg' : 'border border-gray-300 hover:border-gray-400'
|
|
193
|
+
}`}
|
|
194
|
+
style={{ backgroundColor: selectedCategory === cat.id ? colors.primary : undefined }}
|
|
195
|
+
>
|
|
196
|
+
{cat.name}
|
|
197
|
+
</button>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{categoryStyle === 'pills' && (
|
|
203
|
+
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => setSelectedCategory(null)}
|
|
206
|
+
className={`px-5 py-2 rounded-full transition-all text-sm font-medium whitespace-nowrap ${
|
|
207
|
+
selectedCategory === null ? 'text-white shadow-md' : 'bg-gray-100 hover:bg-gray-200'
|
|
208
|
+
}`}
|
|
209
|
+
style={{ backgroundColor: selectedCategory === null ? colors.primary : undefined }}
|
|
210
|
+
>
|
|
211
|
+
All
|
|
212
|
+
</button>
|
|
213
|
+
{categories.map(cat => (
|
|
214
|
+
<button
|
|
215
|
+
key={cat.id}
|
|
216
|
+
onClick={() => setSelectedCategory(cat.id)}
|
|
217
|
+
className={`px-5 py-2 rounded-full transition-all text-sm font-medium whitespace-nowrap ${
|
|
218
|
+
selectedCategory === cat.id ? 'text-white shadow-md' : 'bg-gray-100 hover:bg-gray-200'
|
|
219
|
+
}`}
|
|
220
|
+
style={{ backgroundColor: selectedCategory === cat.id ? colors.primary : undefined }}
|
|
221
|
+
>
|
|
222
|
+
{cat.name}
|
|
223
|
+
</button>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{categoryStyle === 'chips' && (
|
|
229
|
+
<div className="flex gap-2 flex-wrap">
|
|
230
|
+
<button
|
|
231
|
+
onClick={() => setSelectedCategory(null)}
|
|
232
|
+
className={`px-3 py-1.5 rounded-lg transition-all text-xs font-medium flex items-center gap-1.5 ${
|
|
233
|
+
selectedCategory === null ? 'text-white' : 'border border-gray-300 bg-white hover:border-gray-400'
|
|
234
|
+
}`}
|
|
235
|
+
style={{
|
|
236
|
+
backgroundColor: selectedCategory === null ? colors.primary : undefined,
|
|
237
|
+
borderColor: selectedCategory === null ? colors.primary : undefined
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
{selectedCategory === null && <span className="text-[10px]">✓</span>}
|
|
241
|
+
All
|
|
242
|
+
</button>
|
|
243
|
+
{categories.map(cat => (
|
|
244
|
+
<button
|
|
245
|
+
key={cat.id}
|
|
246
|
+
onClick={() => setSelectedCategory(cat.id)}
|
|
247
|
+
className={`px-3 py-1.5 rounded-lg transition-all text-xs font-medium flex items-center gap-1.5 ${
|
|
248
|
+
selectedCategory === cat.id ? 'text-white' : 'border border-gray-300 bg-white hover:border-gray-400'
|
|
249
|
+
}`}
|
|
250
|
+
style={{
|
|
251
|
+
backgroundColor: selectedCategory === cat.id ? colors.primary : undefined,
|
|
252
|
+
borderColor: selectedCategory === cat.id ? colors.primary : undefined
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
{selectedCategory === cat.id && <span className="text-[10px]">✓</span>}
|
|
256
|
+
{cat.name}
|
|
257
|
+
</button>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* Accordion View */}
|
|
265
|
+
{enableCategories && categoryStyle === 'accordion' ? (
|
|
266
|
+
<div className="space-y-4">
|
|
267
|
+
{categories.map(cat => {
|
|
268
|
+
const isExpanded = expandedCategories.has(cat.id);
|
|
269
|
+
const categoryServices = services.filter(s => s.category === cat.id);
|
|
270
|
+
return (
|
|
271
|
+
<div key={cat.id}>
|
|
272
|
+
<button
|
|
273
|
+
onClick={() => {
|
|
274
|
+
const newExpanded = new Set(expandedCategories);
|
|
275
|
+
if (isExpanded) {
|
|
276
|
+
newExpanded.delete(cat.id);
|
|
277
|
+
} else {
|
|
278
|
+
newExpanded.add(cat.id);
|
|
279
|
+
}
|
|
280
|
+
setExpandedCategories(newExpanded);
|
|
281
|
+
}}
|
|
282
|
+
className="w-full flex items-center justify-between py-2 border-b-2 transition-all group"
|
|
283
|
+
style={{ borderColor: isExpanded ? colors.primary : '#e5e7eb' }}
|
|
284
|
+
>
|
|
285
|
+
<span className="font-semibold text-left transition-colors" style={{
|
|
286
|
+
color: isExpanded ? colors.primary : undefined
|
|
287
|
+
}}>{cat.name}</span>
|
|
288
|
+
<ChevronDown
|
|
289
|
+
className={`w-4 h-4 transition-all ${isExpanded ? 'rotate-180' : ''}`}
|
|
290
|
+
style={{ color: isExpanded ? colors.primary : '#9ca3af' }}
|
|
291
|
+
/>
|
|
292
|
+
</button>
|
|
293
|
+
{isExpanded && (
|
|
294
|
+
<div className={`mt-4 ${layout === 'grid' ? `grid ${gridCols} gap-3` : 'space-y-3'}`}>
|
|
295
|
+
{categoryServices.map(service => renderServiceCard(service, layout))}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
})}
|
|
301
|
+
</div>
|
|
302
|
+
) : layout === 'grid' ? (
|
|
303
|
+
<div className={`grid ${gridCols} gap-3`}>
|
|
304
|
+
{filteredServices.map(service => renderServiceCard(service, 'grid'))}
|
|
305
|
+
</div>
|
|
306
|
+
) : (
|
|
307
|
+
<div className="space-y-3">
|
|
308
|
+
{filteredServices.map(service => renderServiceCard(service, 'list'))}
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export default ServiceSelection;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { motion } from 'framer-motion';
|
|
4
|
+
import type { TimeSelectionSettings } from '../../../types';
|
|
5
|
+
import { createEntranceAnimation, createStaggerAnimation } from '../../../styles';
|
|
6
|
+
|
|
7
|
+
interface TimeSelectionProps {
|
|
8
|
+
availableTimes: string[];
|
|
9
|
+
selectedTime: string | null;
|
|
10
|
+
onTimeSelect: (time: string) => void;
|
|
11
|
+
settings: TimeSelectionSettings;
|
|
12
|
+
serviceDuration?: number;
|
|
13
|
+
colors: {
|
|
14
|
+
primary: string;
|
|
15
|
+
secondary: string;
|
|
16
|
+
};
|
|
17
|
+
selectedService?: any; // Selected service object with name, price, description, duration
|
|
18
|
+
selectedDate?: string | null; // Selected date in YYYY-MM-DD format
|
|
19
|
+
selectedAddons?: string[]; // Array of selected addon IDs
|
|
20
|
+
addons?: any[]; // Array of available addon objects
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function TimeSelection({
|
|
24
|
+
availableTimes,
|
|
25
|
+
selectedTime,
|
|
26
|
+
onTimeSelect,
|
|
27
|
+
settings,
|
|
28
|
+
serviceDuration,
|
|
29
|
+
colors,
|
|
30
|
+
selectedService,
|
|
31
|
+
selectedDate,
|
|
32
|
+
selectedAddons = [],
|
|
33
|
+
addons = []
|
|
34
|
+
}: TimeSelectionProps) {
|
|
35
|
+
const timeFormat = settings.timeFormat || '12h';
|
|
36
|
+
const showDuration = settings.showDuration !== false;
|
|
37
|
+
const headerText = settings.headerContent?.value || 'Pick a Time';
|
|
38
|
+
const columns = settings.columns || 3;
|
|
39
|
+
|
|
40
|
+
// Calculate total price including addons
|
|
41
|
+
const totalPrice = () => {
|
|
42
|
+
let total = selectedService?.price || 0;
|
|
43
|
+
if (selectedAddons.length > 0 && addons.length > 0) {
|
|
44
|
+
selectedAddons.forEach(addonId => {
|
|
45
|
+
const addon = addons.find(a => a.id === addonId);
|
|
46
|
+
if (addon) {
|
|
47
|
+
total += addon.addonPrice || addon.price || 0;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return total;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Format date for display
|
|
55
|
+
const formatDate = (dateStr: string) => {
|
|
56
|
+
const date = new Date(dateStr + 'T00:00:00');
|
|
57
|
+
return date.toLocaleDateString('en-US', {
|
|
58
|
+
weekday: 'long',
|
|
59
|
+
month: 'long',
|
|
60
|
+
day: 'numeric',
|
|
61
|
+
year: 'numeric'
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const formatTime = (time: string) => {
|
|
66
|
+
// Parse time string (handles both "HH:MM" and "HH:MM AM/PM" formats)
|
|
67
|
+
const timeParts = time.trim().split(' ');
|
|
68
|
+
const [hourStr, minuteStr] = timeParts[0].split(':');
|
|
69
|
+
let hour = parseInt(hourStr);
|
|
70
|
+
const minute = minuteStr || '00';
|
|
71
|
+
|
|
72
|
+
// If time already has AM/PM, check if we need to convert to 24h
|
|
73
|
+
if (timeParts.length > 1) {
|
|
74
|
+
const period = timeParts[1];
|
|
75
|
+
if (timeFormat === '24h') {
|
|
76
|
+
if (period === 'PM' && hour !== 12) {
|
|
77
|
+
hour += 12;
|
|
78
|
+
} else if (period === 'AM' && hour === 12) {
|
|
79
|
+
hour = 0;
|
|
80
|
+
}
|
|
81
|
+
return `${String(hour).padStart(2, '0')}:${minute}`;
|
|
82
|
+
}
|
|
83
|
+
return time; // Already in 12h format
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Time is in 24h format, convert to 12h if needed
|
|
87
|
+
if (timeFormat === '12h') {
|
|
88
|
+
const period = hour >= 12 ? 'PM' : 'AM';
|
|
89
|
+
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
|
90
|
+
return `${displayHour}:${minute} ${period}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Keep in 24h format
|
|
94
|
+
return `${String(hour).padStart(2, '0')}:${minute}`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const gridCols = {
|
|
98
|
+
2: 'grid-cols-2',
|
|
99
|
+
3: 'grid-cols-2 sm:grid-cols-3',
|
|
100
|
+
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
|
101
|
+
}[columns] || 'grid-cols-3';
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="space-y-6">
|
|
105
|
+
{/* Booking Summary Card */}
|
|
106
|
+
{selectedService && selectedDate && (
|
|
107
|
+
<motion.div
|
|
108
|
+
{...createEntranceAnimation(0.05)}
|
|
109
|
+
className="bg-gradient-to-br from-white to-gray-50/30 rounded-xl border border-gray-200/80 p-6 shadow-sm hover:shadow-md transition-shadow"
|
|
110
|
+
>
|
|
111
|
+
{/* Header */}
|
|
112
|
+
<div className="flex items-center gap-2 mb-4">
|
|
113
|
+
<h3 className="text-sm font-semibold tracking-wide uppercase" style={{ color: colors.primary }}>
|
|
114
|
+
Your Booking
|
|
115
|
+
</h3>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Service */}
|
|
119
|
+
<div className="mb-3">
|
|
120
|
+
<p className="font-bold text-lg text-gray-900">
|
|
121
|
+
{selectedService.name}
|
|
122
|
+
</p>
|
|
123
|
+
{selectedService.duration && (
|
|
124
|
+
<div className="flex items-center gap-1.5 text-xs text-gray-500 mt-1">
|
|
125
|
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
126
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
127
|
+
</svg>
|
|
128
|
+
<span>{selectedService.duration} minutes</span>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Add-ons */}
|
|
134
|
+
{selectedAddons.length > 0 && addons.length > 0 && (
|
|
135
|
+
<div className="mb-4">
|
|
136
|
+
<p className="text-sm font-bold text-gray-900 mb-2">Add-ons</p>
|
|
137
|
+
<div className="space-y-2">
|
|
138
|
+
{selectedAddons.map(addonId => {
|
|
139
|
+
const addon = addons.find(a => a.id === addonId);
|
|
140
|
+
if (!addon) return null;
|
|
141
|
+
return (
|
|
142
|
+
<div key={addonId} className="flex items-center justify-between">
|
|
143
|
+
<span className="text-sm text-gray-700 font-medium">{addon.name}</span>
|
|
144
|
+
<span className="text-sm font-semibold text-gray-900">${addon.addonPrice || addon.price || 0}</span>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Full-width divider */}
|
|
153
|
+
<div className="border-t border-gray-200 -mx-6 mb-4"></div>
|
|
154
|
+
|
|
155
|
+
{/* Date section with emphasis */}
|
|
156
|
+
<div className="bg-gray-50/50 -mx-6 px-6 py-4 mb-4">
|
|
157
|
+
<div className="flex items-center justify-between">
|
|
158
|
+
<div className="flex items-center gap-2">
|
|
159
|
+
<svg className="w-5 h-5" style={{ color: colors.primary }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
160
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
161
|
+
</svg>
|
|
162
|
+
<span className="font-bold text-base text-gray-900">{formatDate(selectedDate)}</span>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Full-width divider */}
|
|
168
|
+
<div className="border-t border-gray-200 -mx-6 mb-4"></div>
|
|
169
|
+
|
|
170
|
+
{/* Total */}
|
|
171
|
+
<div className="flex items-center justify-between">
|
|
172
|
+
<span className="text-base font-bold text-gray-900">Total</span>
|
|
173
|
+
<span className="text-2xl font-bold" style={{ color: colors.primary }}>
|
|
174
|
+
${totalPrice()}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
</motion.div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<motion.h2
|
|
181
|
+
{...createEntranceAnimation(selectedService && selectedDate ? 0.15 : 0.05)}
|
|
182
|
+
className="text-xl sm:text-2xl font-bold text-center sm:text-left"
|
|
183
|
+
style={{ color: colors.primary }}
|
|
184
|
+
>
|
|
185
|
+
{headerText}
|
|
186
|
+
</motion.h2>
|
|
187
|
+
|
|
188
|
+
<motion.div
|
|
189
|
+
className={`grid ${gridCols} gap-3`}
|
|
190
|
+
initial={{ opacity: 0 }}
|
|
191
|
+
animate={{ opacity: 1 }}
|
|
192
|
+
transition={{ duration: 0.3, delay: selectedService && selectedDate ? 0.2 : 0.1 }}
|
|
193
|
+
>
|
|
194
|
+
{availableTimes.map((time, index) => {
|
|
195
|
+
const isSelected = selectedTime === time;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<motion.button
|
|
199
|
+
key={time}
|
|
200
|
+
onClick={() => onTimeSelect(time)}
|
|
201
|
+
className={`p-3 rounded-lg border-2 transition-all text-center hover:scale-105 ${
|
|
202
|
+
isSelected
|
|
203
|
+
? 'border-current text-white shadow-lg'
|
|
204
|
+
: 'border-gray-200 hover:border-gray-300'
|
|
205
|
+
}`}
|
|
206
|
+
style={{
|
|
207
|
+
borderColor: isSelected ? colors.primary : undefined,
|
|
208
|
+
backgroundColor: isSelected ? colors.primary : undefined
|
|
209
|
+
}}
|
|
210
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
211
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
212
|
+
transition={{
|
|
213
|
+
duration: 0.2,
|
|
214
|
+
delay: 0.15 + (index * 0.03),
|
|
215
|
+
ease: 'easeOut'
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<p className="font-medium">{formatTime(time)}</p>
|
|
219
|
+
{showDuration && serviceDuration && (
|
|
220
|
+
<p className="text-xs mt-1 opacity-75">{serviceDuration} min</p>
|
|
221
|
+
)}
|
|
222
|
+
</motion.button>
|
|
223
|
+
);
|
|
224
|
+
})}
|
|
225
|
+
</motion.div>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default TimeSelection;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { ServiceSelection } from './ServiceSelection';
|
|
2
|
+
export { DateSelection } from './DateSelection';
|
|
3
|
+
export { TimeSelection } from './TimeSelection';
|
|
4
|
+
export { AddonsSelection } from './AddonsSelection';
|
|
5
|
+
export { ContactForm } from './ContactForm';
|
|
6
|
+
export { Confirmation } from './Confirmation';
|
|
7
|
+
|
|
8
|
+
// Cycle-aware wrapper
|
|
9
|
+
export { CycleAwareDateSelection, CYCLE_PHASES } from './CycleAwareDateSelection';
|
|
10
|
+
export type { CycleAwareDateSelectionProps } from './CycleAwareDateSelection';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
5
|
+
import { X } from 'lucide-react';
|
|
6
|
+
import type { BottomSheetProps } from '../../types';
|
|
7
|
+
import { animations } from '../../styles';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* BottomSheet - Reusable slide-up modal component
|
|
11
|
+
*
|
|
12
|
+
* Perfect for custom forms, terms of service, or any content that needs
|
|
13
|
+
* to slide up from the bottom of the screen.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <BottomSheet
|
|
18
|
+
* isOpen={isOpen}
|
|
19
|
+
* onClose={() => setIsOpen(false)}
|
|
20
|
+
* title="Color Consultation Form"
|
|
21
|
+
* isRequired={false}
|
|
22
|
+
* >
|
|
23
|
+
* <YourFormContent />
|
|
24
|
+
* </BottomSheet>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function BottomSheet({
|
|
28
|
+
isOpen,
|
|
29
|
+
onClose,
|
|
30
|
+
title,
|
|
31
|
+
children,
|
|
32
|
+
isRequired = false,
|
|
33
|
+
maxHeight = '80vh'
|
|
34
|
+
}: BottomSheetProps) {
|
|
35
|
+
// Prevent body scroll when modal is open
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (isOpen) {
|
|
38
|
+
document.body.style.overflow = 'hidden';
|
|
39
|
+
} else {
|
|
40
|
+
document.body.style.overflow = 'unset';
|
|
41
|
+
}
|
|
42
|
+
return () => {
|
|
43
|
+
document.body.style.overflow = 'unset';
|
|
44
|
+
};
|
|
45
|
+
}, [isOpen]);
|
|
46
|
+
|
|
47
|
+
// Close on escape key (unless required)
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!isRequired && isOpen) {
|
|
50
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
51
|
+
if (e.key === 'Escape') {
|
|
52
|
+
onClose();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
window.addEventListener('keydown', handleEscape);
|
|
56
|
+
return () => window.removeEventListener('keydown', handleEscape);
|
|
57
|
+
}
|
|
58
|
+
}, [isOpen, isRequired, onClose]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<AnimatePresence>
|
|
62
|
+
{isOpen && (
|
|
63
|
+
<>
|
|
64
|
+
{/* Backdrop */}
|
|
65
|
+
<motion.div
|
|
66
|
+
{...animations.backdrop}
|
|
67
|
+
onClick={!isRequired ? onClose : undefined}
|
|
68
|
+
className="fixed inset-0 bg-black/50 z-50"
|
|
69
|
+
style={{ backdropFilter: 'blur(4px)' }}
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
{/* Bottom Sheet */}
|
|
73
|
+
<motion.div
|
|
74
|
+
initial={{ y: '100%' }}
|
|
75
|
+
animate={{ y: 0 }}
|
|
76
|
+
exit={{ y: '100%' }}
|
|
77
|
+
transition={animations.spring}
|
|
78
|
+
className="fixed bottom-0 left-0 right-0 bg-white rounded-t-2xl shadow-2xl z-50"
|
|
79
|
+
style={{ maxHeight }}
|
|
80
|
+
>
|
|
81
|
+
{/* Handle bar */}
|
|
82
|
+
<div className="flex justify-center pt-3 pb-2">
|
|
83
|
+
<div className="w-12 h-1 bg-gray-300 rounded-full" />
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Header */}
|
|
87
|
+
<div className="flex items-center justify-between px-6 pb-4 border-b border-gray-200">
|
|
88
|
+
<div className="flex items-center gap-2">
|
|
89
|
+
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
|
90
|
+
{isRequired && (
|
|
91
|
+
<span className="text-xs font-medium px-2 py-0.5 bg-amber-100 text-amber-800 rounded">
|
|
92
|
+
Required
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
{!isRequired && (
|
|
97
|
+
<button
|
|
98
|
+
onClick={onClose}
|
|
99
|
+
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
100
|
+
aria-label="Close"
|
|
101
|
+
>
|
|
102
|
+
<X className="w-5 h-5" />
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Content */}
|
|
108
|
+
<div className="overflow-y-auto" style={{ maxHeight: `calc(${maxHeight} - 120px)` }}>
|
|
109
|
+
<div className="px-6 py-6">
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</motion.div>
|
|
114
|
+
</>
|
|
115
|
+
)}
|
|
116
|
+
</AnimatePresence>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default BottomSheet;
|