@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,5 @@
1
+ export { BookingFlow } from './BookingFlow';
2
+ export type { BookingFlowProps, BookingFlowConfig } from './BookingFlow';
3
+
4
+ // Re-export step components for convenience
5
+ export * from './steps';
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import { motion } from 'framer-motion';
4
+ import type { Addon, AddonSelectionSettings } from '../../../types';
5
+ import { createEntranceAnimation } from '../../../styles';
6
+
7
+ interface AddonsSelectionProps {
8
+ addons: Addon[];
9
+ selectedAddons: string[];
10
+ onAddonsChange: (addonIds: string[]) => void;
11
+ settings: AddonSelectionSettings;
12
+ colors: {
13
+ primary: string;
14
+ secondary: string;
15
+ };
16
+ }
17
+
18
+ export function AddonsSelection({
19
+ addons,
20
+ selectedAddons,
21
+ onAddonsChange,
22
+ settings,
23
+ colors
24
+ }: AddonsSelectionProps) {
25
+ const headerText = settings.headerContent?.value || 'Add Extras';
26
+ const subheaderText = settings.subheaderContent?.value || 'Enhance your experience with these optional add-ons';
27
+ const showPricing = settings.showPricing !== false;
28
+ const showDuration = settings.showDuration !== false;
29
+
30
+ const toggleAddon = (addonId: string) => {
31
+ const newSelection = selectedAddons.includes(addonId)
32
+ ? selectedAddons.filter(id => id !== addonId)
33
+ : [...selectedAddons, addonId];
34
+ onAddonsChange(newSelection);
35
+ };
36
+
37
+ return (
38
+ <div className="space-y-4">
39
+ <motion.div
40
+ {...createEntranceAnimation(0.05)}
41
+ className="text-center sm:text-left"
42
+ >
43
+ <h2 className="text-xl sm:text-2xl font-bold mb-2" style={{ color: colors.primary }}>
44
+ {headerText}
45
+ </h2>
46
+ <p className="text-sm opacity-70">{subheaderText}</p>
47
+ </motion.div>
48
+
49
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
50
+ {addons.map((addon, index) => {
51
+ const isSelected = selectedAddons.includes(addon.id);
52
+ return (
53
+ <motion.button
54
+ key={addon.id}
55
+ onClick={() => toggleAddon(addon.id)}
56
+ className={`p-4 rounded-lg border-2 transition-all text-left relative ${
57
+ isSelected
58
+ ? 'border-current shadow-lg'
59
+ : 'border-gray-200 hover:border-gray-300'
60
+ }`}
61
+ style={{
62
+ borderColor: isSelected ? colors.primary : undefined,
63
+ backgroundColor: isSelected ? `${colors.primary}10` : undefined
64
+ }}
65
+ initial={{ opacity: 0, y: 10 }}
66
+ animate={{ opacity: 1, y: 0 }}
67
+ transition={{
68
+ duration: 0.3,
69
+ delay: 0.1 + (index * 0.05)
70
+ }}
71
+ >
72
+ {/* Checkmark indicator */}
73
+ {isSelected && (
74
+ <div
75
+ className="absolute top-3 right-3 w-6 h-6 rounded-full flex items-center justify-center text-white text-sm"
76
+ style={{ backgroundColor: colors.primary }}
77
+ >
78
+
79
+ </div>
80
+ )}
81
+
82
+ <p className="font-semibold mb-1 pr-8">{addon.name}</p>
83
+ <p className="text-xs opacity-70 mb-3">{addon.description}</p>
84
+ <div className="flex justify-between items-center text-sm">
85
+ {showPricing && <span className="font-bold" style={{ color: colors.primary }}>+${addon.price}</span>}
86
+ {showDuration && <span className="opacity-60">+{addon.duration} min</span>}
87
+ </div>
88
+ </motion.button>
89
+ );
90
+ })}
91
+ </div>
92
+
93
+ {selectedAddons.length > 0 && (
94
+ <motion.div
95
+ className="bg-gray-50 rounded-lg p-4 mt-4"
96
+ initial={{ opacity: 0, scale: 0.95 }}
97
+ animate={{ opacity: 1, scale: 1 }}
98
+ >
99
+ <p className="text-sm font-medium mb-2">Selected Add-ons:</p>
100
+ <div className="space-y-1">
101
+ {selectedAddons.map(addonId => {
102
+ const addon = addons.find(a => a.id === addonId);
103
+ if (!addon) return null;
104
+ return (
105
+ <div key={addonId} className="flex justify-between text-sm">
106
+ <span>{addon.name}</span>
107
+ <span className="font-medium" style={{ color: colors.primary }}>+${addon.price}</span>
108
+ </div>
109
+ );
110
+ })}
111
+ </div>
112
+ </motion.div>
113
+ )}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ export default AddonsSelection;
@@ -0,0 +1,185 @@
1
+ 'use client';
2
+
3
+ import { motion } from 'framer-motion';
4
+ import type { Service, Addon, PaymentConfig } from '../../../types';
5
+ import { createEntranceAnimation } from '../../../styles';
6
+
7
+ interface ConfirmationProps {
8
+ service?: Service;
9
+ addons?: Addon[];
10
+ selectedDate?: string;
11
+ selectedTime?: string;
12
+ paymentProvider: 'square' | 'stripe';
13
+ paymentConfig: PaymentConfig;
14
+ onConfirm: () => Promise<void>;
15
+ colors: {
16
+ primary: string;
17
+ secondary: string;
18
+ };
19
+ isPreview?: boolean;
20
+ }
21
+
22
+ export function Confirmation({
23
+ service,
24
+ addons = [],
25
+ selectedDate,
26
+ selectedTime,
27
+ paymentProvider,
28
+ paymentConfig,
29
+ onConfirm,
30
+ colors,
31
+ isPreview = false
32
+ }: ConfirmationProps) {
33
+ const servicePrice = service?.price || 0;
34
+ // Fix: Use addonPrice field (or fallback to price for backward compatibility)
35
+ const addonsPrice = addons.reduce((sum, addon) => sum + (addon.addonPrice || addon.price || 0), 0);
36
+ const totalPrice = servicePrice + addonsPrice;
37
+ const serviceDuration = service?.duration || 0;
38
+ const addonsDuration = addons.reduce((sum, addon) => sum + (addon.duration || 0), 0);
39
+ const totalDuration = serviceDuration + addonsDuration;
40
+
41
+ const depositPercentage = paymentConfig.depositPercentage || 20;
42
+ const depositAmount = (totalPrice * (depositPercentage / 100)).toFixed(2);
43
+ const totalAmount = totalPrice.toFixed(2);
44
+ const balanceAmount = (parseFloat(totalAmount) - parseFloat(depositAmount)).toFixed(2);
45
+
46
+ return (
47
+ <div className="space-y-4">
48
+ <motion.h2
49
+ {...createEntranceAnimation(0.05)}
50
+ className="text-xl font-bold text-center"
51
+ style={{ color: colors.primary }}
52
+ >
53
+ Confirm & Pay
54
+ </motion.h2>
55
+
56
+ {/* Appointment Summary - Compact */}
57
+ <motion.div
58
+ className="bg-gray-50 rounded-lg p-4"
59
+ initial={{ opacity: 0, y: 10 }}
60
+ animate={{ opacity: 1, y: 0 }}
61
+ transition={{ duration: 0.3, delay: 0.1 }}
62
+ >
63
+ <div className="space-y-2">
64
+ {service && (
65
+ <div className="flex justify-between items-center text-sm">
66
+ <div>
67
+ <p className="font-medium">{service.name}</p>
68
+ <p className="text-xs opacity-70">{totalDuration} min total</p>
69
+ </div>
70
+ <span className="font-bold" style={{ color: colors.primary }}>${servicePrice}</span>
71
+ </div>
72
+ )}
73
+ {addons.length > 0 && (
74
+ <div className="space-y-1 text-xs">
75
+ {addons.map(addon => (
76
+ <div key={addon.id} className="flex justify-between opacity-70">
77
+ <span>+ {addon.name}</span>
78
+ <span>+${addon.addonPrice || addon.price || 0}</span>
79
+ </div>
80
+ ))}
81
+ </div>
82
+ )}
83
+ {selectedDate && (
84
+ <div className="flex justify-between items-center text-sm pt-2 border-t border-gray-200">
85
+ <span className="opacity-70">
86
+ {new Date(selectedDate + 'T00:00:00').toLocaleDateString('en-US', {
87
+ weekday: 'long',
88
+ year: 'numeric',
89
+ month: 'long',
90
+ day: 'numeric'
91
+ })}
92
+ </span>
93
+ <span>{selectedTime}</span>
94
+ </div>
95
+ )}
96
+ <div className="pt-2 mt-2 border-t border-gray-200 flex justify-between items-center">
97
+ <span className="text-sm font-medium">Due Today</span>
98
+ <span className="text-lg font-bold" style={{ color: colors.primary }}>${depositAmount}</span>
99
+ </div>
100
+ </div>
101
+ </motion.div>
102
+
103
+ {/* Payment Form - Preview or Live */}
104
+ <motion.div
105
+ initial={{ opacity: 0, y: 10 }}
106
+ animate={{ opacity: 1, y: 0 }}
107
+ transition={{ duration: 0.3, delay: 0.2 }}
108
+ >
109
+ {isPreview ? (
110
+ // Preview Mode: Show placeholder UI
111
+ <div className="border border-gray-200 rounded-lg p-4 bg-white">
112
+ <div className="space-y-2">
113
+ <div className="h-10 bg-gray-50 border border-gray-200 rounded flex items-center px-3">
114
+ <span className="text-xs text-gray-400">Card Number</span>
115
+ </div>
116
+ <div className="grid grid-cols-2 gap-2">
117
+ <div className="h-10 bg-gray-50 border border-gray-200 rounded flex items-center px-3">
118
+ <span className="text-xs text-gray-400">MM/YY</span>
119
+ </div>
120
+ <div className="h-10 bg-gray-50 border border-gray-200 rounded flex items-center px-3">
121
+ <span className="text-xs text-gray-400">CVV</span>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ <div className="mt-3 flex items-center justify-center gap-1">
126
+ <svg className="w-3 h-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
127
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
128
+ </svg>
129
+ <span className="text-xs text-gray-400">
130
+ Powered by {paymentProvider === 'square' ? 'Square' : 'Stripe'}
131
+ </span>
132
+ </div>
133
+ </div>
134
+ ) : (
135
+ // Live Mode: Real payment processing
136
+ // Note: The actual payment form will be rendered by the tenant app
137
+ // This component will trigger payment initialization when mounted
138
+ <div className="border border-gray-200 rounded-lg p-4 bg-white">
139
+ <div className="text-center py-8 text-sm text-gray-500">
140
+ <div className="animate-pulse">
141
+ Initializing {paymentProvider === 'square' ? 'Square' : 'Stripe'} payment...
142
+ </div>
143
+ <div className="mt-4 text-xs text-gray-400">
144
+ Please wait while we securely load the payment form
145
+ </div>
146
+ </div>
147
+ </div>
148
+ )}
149
+ </motion.div>
150
+
151
+ {/* Terms - Compact */}
152
+ <motion.div
153
+ initial={{ opacity: 0, y: 10 }}
154
+ animate={{ opacity: 1, y: 0 }}
155
+ transition={{ duration: 0.3, delay: 0.3 }}
156
+ >
157
+ <div className="space-y-2">
158
+ <label className="flex items-start gap-2 cursor-pointer">
159
+ <input
160
+ type="checkbox"
161
+ className="mt-0.5 w-4 h-4 rounded border-gray-300"
162
+ style={{ accentColor: colors.primary }}
163
+ />
164
+ <span className="text-xs">
165
+ I agree to the <a href="#" className="underline" style={{ color: colors.primary }}>cancellation policy</a> and understand the ${balanceAmount} balance is due at appointment.
166
+ </span>
167
+ </label>
168
+
169
+ <label className="flex items-start gap-2 cursor-pointer">
170
+ <input
171
+ type="checkbox"
172
+ className="mt-0.5 w-4 h-4 rounded border-gray-300"
173
+ style={{ accentColor: colors.primary }}
174
+ />
175
+ <span className="text-xs">
176
+ I agree to the <a href="#" className="underline" style={{ color: colors.primary }}>terms of service</a>.
177
+ </span>
178
+ </label>
179
+ </div>
180
+ </motion.div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export default Confirmation;
@@ -0,0 +1,292 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { motion } from 'framer-motion';
5
+ import type { ContactInfo, ContactFormSettings, OptInModule } from '../../../types';
6
+ import { createEntranceAnimation } from '../../../styles';
7
+ import { BottomSheet } from '../../BottomSheet';
8
+
9
+ interface ContactFormProps {
10
+ contactInfo: Partial<ContactInfo>;
11
+ onContactInfoChange: (info: Partial<ContactInfo>) => void;
12
+ settings: ContactFormSettings;
13
+ optInModules?: OptInModule[];
14
+ onOptInData?: (moduleId: string, data: any) => void;
15
+ colors: {
16
+ primary: string;
17
+ secondary: string;
18
+ };
19
+ isPreview?: boolean; // Disable validation in preview mode
20
+ }
21
+
22
+ export function ContactForm({
23
+ contactInfo,
24
+ onContactInfoChange,
25
+ settings,
26
+ optInModules = [],
27
+ onOptInData,
28
+ colors,
29
+ isPreview = false
30
+ }: ContactFormProps) {
31
+ const [isModalOpen, setIsModalOpen] = useState(false);
32
+ const [currentOptInModule, setCurrentOptInModule] = useState<OptInModule | null>(null);
33
+ const [optInChecked, setOptInChecked] = useState<Record<string, boolean>>({});
34
+ const [errors, setErrors] = useState<Record<string, string>>({});
35
+ const [touched, setTouched] = useState<Record<string, boolean>>({});
36
+
37
+ const headerText = settings.headerContent?.value || 'Your Information';
38
+
39
+ // Validation functions
40
+ const validateEmail = (email: string): string | null => {
41
+ if (!email) return 'Email is required';
42
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43
+ if (!emailRegex.test(email)) return 'Please enter a valid email address';
44
+ return null;
45
+ };
46
+
47
+ const validatePhone = (phone: string): string | null => {
48
+ if (!phone) return 'Phone number is required';
49
+ // Remove all non-digit characters
50
+ const digits = phone.replace(/\D/g, '');
51
+ if (digits.length < 10) return 'Please enter a valid phone number';
52
+ return null;
53
+ };
54
+
55
+ const validateName = (name: string): string | null => {
56
+ if (!name || name.trim().length === 0) return 'Name is required';
57
+ if (name.trim().length < 2) return 'Name must be at least 2 characters';
58
+ return null;
59
+ };
60
+
61
+ const validateField = (field: keyof ContactInfo, value: string): string | null => {
62
+ switch (field) {
63
+ case 'email':
64
+ return validateEmail(value);
65
+ case 'phone':
66
+ return validatePhone(value);
67
+ case 'name':
68
+ return validateName(value);
69
+ default:
70
+ return null;
71
+ }
72
+ };
73
+
74
+ const formFields = [
75
+ { label: 'Name *', type: 'text' as const, placeholder: 'Your full name', field: 'name' },
76
+ { label: 'Email *', type: 'email' as const, placeholder: 'your@email.com', field: 'email' },
77
+ { label: 'Phone *', type: 'tel' as const, placeholder: '(555) 123-4567', field: 'phone' },
78
+ { label: 'Notes (optional)', type: 'textarea' as const, placeholder: 'Any special requests or notes...', field: 'notes' }
79
+ ];
80
+
81
+ const handleFieldChange = (field: keyof ContactInfo, value: string) => {
82
+ // Update the value
83
+ onContactInfoChange({ ...contactInfo, [field]: value });
84
+
85
+ // Skip validation in preview mode
86
+ if (isPreview) return;
87
+
88
+ // Validate and update errors if field has been touched
89
+ if (touched[field]) {
90
+ const error = validateField(field, value);
91
+ setErrors(prev => ({
92
+ ...prev,
93
+ [field]: error || ''
94
+ }));
95
+ }
96
+ };
97
+
98
+ const handleBlur = (field: keyof ContactInfo) => {
99
+ // Skip validation in preview mode
100
+ if (isPreview) return;
101
+
102
+ // Mark field as touched
103
+ setTouched(prev => ({ ...prev, [field]: true }));
104
+
105
+ // Validate on blur
106
+ const value = contactInfo[field] || '';
107
+ const error = validateField(field, value);
108
+ setErrors(prev => ({
109
+ ...prev,
110
+ [field]: error || ''
111
+ }));
112
+ };
113
+
114
+ const handleOptInChange = (module: OptInModule, checked: boolean) => {
115
+ setOptInChecked(prev => ({ ...prev, [module.id]: checked }));
116
+ if (checked) {
117
+ setCurrentOptInModule(module);
118
+ setIsModalOpen(true);
119
+ }
120
+ };
121
+
122
+ return (
123
+ <>
124
+ <div className="space-y-4">
125
+ <motion.h2
126
+ {...createEntranceAnimation(0.05)}
127
+ className="text-xl sm:text-2xl font-bold text-center sm:text-left"
128
+ style={{ color: colors.primary }}
129
+ >
130
+ {headerText}
131
+ </motion.h2>
132
+
133
+ <div className="space-y-3">
134
+ {formFields.map((field, index) => (
135
+ <motion.div
136
+ key={field.label}
137
+ initial={{ opacity: 0, x: -10 }}
138
+ animate={{ opacity: 1, x: 0 }}
139
+ transition={{
140
+ duration: 0.3,
141
+ delay: 0.1 + (index * 0.05),
142
+ ease: 'easeOut'
143
+ }}
144
+ >
145
+ <label className="block text-sm font-medium mb-1">{field.label}</label>
146
+ {field.type === 'textarea' ? (
147
+ <>
148
+ <textarea
149
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:border-transparent resize-none transition-all ${
150
+ !isPreview && errors[field.field] && touched[field.field] ? 'border-red-500' : 'border-gray-300'
151
+ }`}
152
+ style={{ '--tw-ring-color': colors.primary } as any}
153
+ rows={3}
154
+ placeholder={field.placeholder}
155
+ value={contactInfo[field.field as keyof ContactInfo] || ''}
156
+ onChange={(e) => handleFieldChange(field.field as keyof ContactInfo, e.target.value)}
157
+ onBlur={() => handleBlur(field.field as keyof ContactInfo)}
158
+ />
159
+ {!isPreview && errors[field.field] && touched[field.field] && (
160
+ <p className="text-red-500 text-xs mt-1">{errors[field.field]}</p>
161
+ )}
162
+ </>
163
+ ) : (
164
+ <>
165
+ <input
166
+ type={field.type}
167
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:border-transparent transition-all ${
168
+ !isPreview && errors[field.field] && touched[field.field] ? 'border-red-500' : 'border-gray-300'
169
+ }`}
170
+ style={{ '--tw-ring-color': colors.primary } as any}
171
+ placeholder={field.placeholder}
172
+ value={contactInfo[field.field as keyof ContactInfo] || ''}
173
+ onChange={(e) => handleFieldChange(field.field as keyof ContactInfo, e.target.value)}
174
+ onBlur={() => handleBlur(field.field as keyof ContactInfo)}
175
+ />
176
+ {!isPreview && errors[field.field] && touched[field.field] && (
177
+ <p className="text-red-500 text-xs mt-1">{errors[field.field]}</p>
178
+ )}
179
+ </>
180
+ )}
181
+ </motion.div>
182
+ ))}
183
+
184
+ {/* Opt-in Modules */}
185
+ {optInModules.length > 0 && (
186
+ <motion.div
187
+ className="pt-3 border-t border-gray-200 space-y-3"
188
+ initial={{ opacity: 0, y: 10 }}
189
+ animate={{ opacity: 1, y: 0 }}
190
+ transition={{ duration: 0.3, delay: 0.3 }}
191
+ >
192
+ {optInModules.map(module => (
193
+ <label key={module.id} className="flex items-start gap-3 cursor-pointer group">
194
+ <input
195
+ type="checkbox"
196
+ checked={optInChecked[module.id] || false}
197
+ onChange={(e) => handleOptInChange(module, e.target.checked)}
198
+ className="mt-1 w-4 h-4 rounded border-gray-300"
199
+ style={{ accentColor: colors.primary }}
200
+ />
201
+ <div>
202
+ <span className="text-sm font-medium">
203
+ {module.label}
204
+ {module.required && <span className="text-red-500 ml-1">*</span>}
205
+ </span>
206
+ <p className="text-xs text-gray-500 mt-0.5">
207
+ Check this to provide additional information
208
+ </p>
209
+ </div>
210
+ </label>
211
+ ))}
212
+ </motion.div>
213
+ )}
214
+ </div>
215
+ </div>
216
+
217
+ {/* Bottom Sheet Modal for Opt-in Forms */}
218
+ {currentOptInModule && (
219
+ <BottomSheet
220
+ isOpen={isModalOpen}
221
+ onClose={() => setIsModalOpen(false)}
222
+ title={currentOptInModule.formTitle || 'Additional Information'}
223
+ isRequired={currentOptInModule.required}
224
+ >
225
+ {/* Check if module has custom render function */}
226
+ {currentOptInModule.renderCustomForm ? (
227
+ currentOptInModule.renderCustomForm(
228
+ () => setIsModalOpen(false),
229
+ (data: any) => {
230
+ if (onOptInData) {
231
+ onOptInData(currentOptInModule.id, data);
232
+ }
233
+ },
234
+ colors
235
+ )
236
+ ) : (
237
+ /* Standard form rendering */
238
+ <div className="space-y-4">
239
+ {currentOptInModule.formFields?.map(field => (
240
+ <div key={field.id}>
241
+ <label className="block text-sm font-medium mb-1">
242
+ {field.label}
243
+ {field.required && <span className="text-red-500 ml-1">*</span>}
244
+ </label>
245
+ {field.type === 'textarea' ? (
246
+ <textarea
247
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent resize-none"
248
+ style={{ '--tw-ring-color': colors.primary } as any}
249
+ rows={3}
250
+ placeholder={field.placeholder}
251
+ />
252
+ ) : field.type === 'select' && field.options ? (
253
+ <select
254
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent"
255
+ style={{ '--tw-ring-color': colors.primary } as any}
256
+ >
257
+ {field.options.map(opt => (
258
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
259
+ ))}
260
+ </select>
261
+ ) : (
262
+ <input
263
+ type={field.type}
264
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent"
265
+ style={{ '--tw-ring-color': colors.primary } as any}
266
+ placeholder={field.placeholder}
267
+ />
268
+ )}
269
+ </div>
270
+ ))}
271
+
272
+ <button
273
+ onClick={() => {
274
+ if (onOptInData && currentOptInModule) {
275
+ onOptInData(currentOptInModule.id, {}); // Collect form data here
276
+ }
277
+ setIsModalOpen(false);
278
+ }}
279
+ className="w-full py-3 rounded-lg text-white font-medium transition-all hover:opacity-90"
280
+ style={{ backgroundColor: colors.primary }}
281
+ >
282
+ Save & Continue
283
+ </button>
284
+ </div>
285
+ )}
286
+ </BottomSheet>
287
+ )}
288
+ </>
289
+ );
290
+ }
291
+
292
+ export default ContactForm;