@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.
Files changed (83) hide show
  1. package/README.md +319 -0
  2. package/TENANT_DATA_INTEGRATION.md +402 -0
  3. package/TENANT_SETUP.md +316 -0
  4. package/components/BookingFlow/BookingFlow.tsx +790 -0
  5. package/components/BookingFlow/index.ts +5 -0
  6. package/components/BookingFlow/steps/AddonsSelection.tsx +118 -0
  7. package/components/BookingFlow/steps/Confirmation.tsx +185 -0
  8. package/components/BookingFlow/steps/ContactForm.tsx +292 -0
  9. package/components/BookingFlow/steps/CycleAwareDateSelection.tsx +277 -0
  10. package/components/BookingFlow/steps/DateSelection.tsx +473 -0
  11. package/components/BookingFlow/steps/ServiceSelection.tsx +315 -0
  12. package/components/BookingFlow/steps/TimeSelection.tsx +230 -0
  13. package/components/BookingFlow/steps/index.ts +10 -0
  14. package/components/BottomSheet/index.tsx +120 -0
  15. package/components/Forms/FormBlock.tsx +283 -0
  16. package/components/Forms/FormField.tsx +385 -0
  17. package/components/Forms/FormRenderer.tsx +216 -0
  18. package/components/Forms/FormValidation.ts +122 -0
  19. package/components/Forms/index.ts +4 -0
  20. package/components/HoldTimer/HoldTimer.tsx +266 -0
  21. package/components/HoldTimer/index.ts +2 -0
  22. package/components/SectionRenderer.tsx +558 -0
  23. package/components/Sections/About.tsx +145 -0
  24. package/components/Sections/BeforeAfter.tsx +81 -0
  25. package/components/Sections/BookingSection.tsx +76 -0
  26. package/components/Sections/Contact.tsx +103 -0
  27. package/components/Sections/FAQSection.tsx +239 -0
  28. package/components/Sections/FeatureContent.tsx +113 -0
  29. package/components/Sections/FeaturedLink.tsx +103 -0
  30. package/components/Sections/FixedInfoCard.tsx +189 -0
  31. package/components/Sections/Gallery.tsx +83 -0
  32. package/components/Sections/Header.tsx +78 -0
  33. package/components/Sections/Hero.tsx +178 -0
  34. package/components/Sections/ImageSection.tsx +147 -0
  35. package/components/Sections/InstagramFeed.tsx +38 -0
  36. package/components/Sections/LinkList.tsx +76 -0
  37. package/components/Sections/LocationMap.tsx +202 -0
  38. package/components/Sections/Logo.tsx +61 -0
  39. package/components/Sections/MinimalFooter.tsx +78 -0
  40. package/components/Sections/MinimalHeader.tsx +81 -0
  41. package/components/Sections/MinimalNavigation.tsx +63 -0
  42. package/components/Sections/Navbar.tsx +258 -0
  43. package/components/Sections/PricingTable.tsx +106 -0
  44. package/components/Sections/ScrollingTextDivider.tsx +138 -0
  45. package/components/Sections/ScrollingTextDivider.tsx.bak +138 -0
  46. package/components/Sections/ServicesPreview.tsx +129 -0
  47. package/components/Sections/SocialBar.tsx +177 -0
  48. package/components/Sections/Team.tsx +80 -0
  49. package/components/Sections/Testimonials.tsx +92 -0
  50. package/components/Sections/TextSection.tsx +116 -0
  51. package/components/Sections/VideoSection.tsx +178 -0
  52. package/components/Sections/index.ts +57 -0
  53. package/components/index.ts +21 -0
  54. package/dist/index-DAai7Glf.d.mts +474 -0
  55. package/dist/index-DAai7Glf.d.ts +474 -0
  56. package/dist/index.d.mts +1075 -0
  57. package/dist/index.d.ts +1075 -0
  58. package/dist/index.js +22 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/index.mjs +22 -0
  61. package/dist/index.mjs.map +1 -0
  62. package/dist/styles/index.d.mts +1 -0
  63. package/dist/styles/index.d.ts +1 -0
  64. package/dist/styles/index.js +2 -0
  65. package/dist/styles/index.js.map +1 -0
  66. package/dist/styles/index.mjs +2 -0
  67. package/dist/styles/index.mjs.map +1 -0
  68. package/docs/API.md +849 -0
  69. package/docs/CALLBACKS.md +760 -0
  70. package/docs/COMPLETE_SESSION_SUMMARY.md +404 -0
  71. package/docs/DATA_SHAPES.md +684 -0
  72. package/docs/MIGRATION.md +662 -0
  73. package/docs/PAYMENT_INTEGRATION.md +766 -0
  74. package/docs/SESSION_SUMMARY.md +185 -0
  75. package/docs/STYLING.md +735 -0
  76. package/index.ts +4 -0
  77. package/lib/storage.ts +239 -0
  78. package/package.json +59 -0
  79. package/styles/animations.ts +210 -0
  80. package/styles/index.ts +1 -0
  81. package/tsconfig.json +32 -0
  82. package/tsup.config.ts +13 -0
  83. 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;