@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,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;
|