@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,216 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import FormBlock from './FormBlock';
|
|
5
|
+
import { validateField, validateForm } from './FormValidation';
|
|
6
|
+
|
|
7
|
+
interface FormField {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
type: 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date' | 'photo' | 'image';
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
required: boolean;
|
|
13
|
+
options?: Array<{ value: string; label: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FormBlockData {
|
|
17
|
+
id: string;
|
|
18
|
+
type: 'section' | 'text' | 'image';
|
|
19
|
+
title?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
content?: string;
|
|
22
|
+
alignment?: 'left' | 'center' | 'right';
|
|
23
|
+
imageUrl?: string;
|
|
24
|
+
fields?: FormField[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface FormSettings {
|
|
28
|
+
title: string;
|
|
29
|
+
description: string;
|
|
30
|
+
submitButtonText: string;
|
|
31
|
+
successMessage: string;
|
|
32
|
+
blocks: FormBlockData[];
|
|
33
|
+
styling?: {
|
|
34
|
+
alignment?: 'left' | 'center' | 'right';
|
|
35
|
+
fieldStyle?: 'outlined' | 'filled' | 'underlined' | 'minimal';
|
|
36
|
+
sectionBackground?: string;
|
|
37
|
+
fieldBorder?: string;
|
|
38
|
+
buttonColor?: string;
|
|
39
|
+
labelColor?: string;
|
|
40
|
+
textColor?: string;
|
|
41
|
+
};
|
|
42
|
+
notifications: {
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
adminEmail: string;
|
|
45
|
+
emailSubject: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface FormRendererProps {
|
|
50
|
+
form: {
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
type: string;
|
|
55
|
+
settings: FormSettings;
|
|
56
|
+
};
|
|
57
|
+
businessId: string;
|
|
58
|
+
onSuccess?: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function FormRenderer({ form, businessId, onSuccess }: FormRendererProps) {
|
|
62
|
+
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
63
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
64
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
65
|
+
const [isSuccess, setIsSuccess] = useState(false);
|
|
66
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
67
|
+
|
|
68
|
+
const handleFieldChange = (fieldId: string, value: any, field: FormField) => {
|
|
69
|
+
setFormData(prev => ({ ...prev, [fieldId]: value }));
|
|
70
|
+
|
|
71
|
+
// Clear error when user starts typing
|
|
72
|
+
if (errors[fieldId]) {
|
|
73
|
+
const validation = validateField(value, field);
|
|
74
|
+
setErrors(prev => {
|
|
75
|
+
const newErrors = { ...prev };
|
|
76
|
+
if (validation.isValid) {
|
|
77
|
+
delete newErrors[fieldId];
|
|
78
|
+
} else {
|
|
79
|
+
newErrors[fieldId] = validation.error || '';
|
|
80
|
+
}
|
|
81
|
+
return newErrors;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
setSubmitError(null);
|
|
89
|
+
|
|
90
|
+
// Collect all fields from all section blocks
|
|
91
|
+
const allFields: FormField[] = [];
|
|
92
|
+
form.settings.blocks.forEach(block => {
|
|
93
|
+
if (block.type === 'section' && block.fields) {
|
|
94
|
+
allFields.push(...block.fields);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Validate all fields
|
|
99
|
+
const validation = validateForm(formData, allFields);
|
|
100
|
+
if (!validation.isValid) {
|
|
101
|
+
setErrors(validation.errors);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setIsSubmitting(true);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Use different API endpoints for tenant vs dashboard
|
|
109
|
+
// Tenant (Vite): /api/forms/submit
|
|
110
|
+
// Dashboard (Next.js): /api/studio/forms/${businessId}/${form.slug}/submit
|
|
111
|
+
const isTenant = typeof window !== 'undefined' && (import.meta as any).env;
|
|
112
|
+
const apiUrl = isTenant
|
|
113
|
+
? '/api/forms/submit'
|
|
114
|
+
: `/api/studio/forms/${businessId}/${form.slug}/submit`;
|
|
115
|
+
|
|
116
|
+
const response = await fetch(apiUrl, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
formId: form.id,
|
|
123
|
+
slug: form.slug,
|
|
124
|
+
data: formData
|
|
125
|
+
})
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
const error = await response.json();
|
|
130
|
+
throw new Error(error.message || 'Failed to submit form');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setIsSuccess(true);
|
|
134
|
+
setFormData({});
|
|
135
|
+
setErrors({});
|
|
136
|
+
|
|
137
|
+
if (onSuccess) {
|
|
138
|
+
onSuccess();
|
|
139
|
+
}
|
|
140
|
+
} catch (error: any) {
|
|
141
|
+
console.error('Form submission error:', error);
|
|
142
|
+
setSubmitError(error.message || 'Failed to submit form. Please try again.');
|
|
143
|
+
} finally {
|
|
144
|
+
setIsSubmitting(false);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (isSuccess) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="max-w-2xl mx-auto px-4 py-12">
|
|
151
|
+
<div className="bg-green-50 border border-green-200 rounded-lg p-8 text-center">
|
|
152
|
+
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
153
|
+
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
154
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
155
|
+
</svg>
|
|
156
|
+
</div>
|
|
157
|
+
<h3 className="text-xl font-semibold mb-2" style={{ color: form.settings.styling?.labelColor || '#000000' }}>Success!</h3>
|
|
158
|
+
<p style={{ color: form.settings.styling?.textColor || '#000000' }}>{form.settings.successMessage}</p>
|
|
159
|
+
<button
|
|
160
|
+
onClick={() => setIsSuccess(false)}
|
|
161
|
+
className="mt-6 px-4 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
|
162
|
+
>
|
|
163
|
+
Submit Another Response
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const alignment = form.settings.styling?.alignment || 'left';
|
|
171
|
+
const alignmentClass = alignment === 'center' ? 'text-center' : alignment === 'right' ? 'text-right' : 'text-left';
|
|
172
|
+
const buttonColor = form.settings.styling?.buttonColor || '#CAC426';
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="max-w-2xl mx-auto px-4 py-12">
|
|
176
|
+
<div className={`mb-8 ${alignmentClass}`}>
|
|
177
|
+
<h1 className="text-3xl font-bold mb-2" style={{ color: form.settings.styling?.textColor || '#000000' }}>{form.settings.title}</h1>
|
|
178
|
+
{form.settings.description && (
|
|
179
|
+
<p style={{ color: form.settings.styling?.textColor || '#000000' }}>{form.settings.description}</p>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<form onSubmit={handleSubmit} className="space-y-8">
|
|
184
|
+
{form.settings.blocks.map((block) => (
|
|
185
|
+
<FormBlock
|
|
186
|
+
key={block.id}
|
|
187
|
+
block={block}
|
|
188
|
+
formData={formData}
|
|
189
|
+
errors={errors}
|
|
190
|
+
onChange={handleFieldChange}
|
|
191
|
+
businessId={businessId}
|
|
192
|
+
formId={form.id}
|
|
193
|
+
styling={form.settings.styling}
|
|
194
|
+
/>
|
|
195
|
+
))}
|
|
196
|
+
|
|
197
|
+
{submitError && (
|
|
198
|
+
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
199
|
+
<p className="text-sm text-red-600">{submitError}</p>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
<div className="pt-6">
|
|
204
|
+
<button
|
|
205
|
+
type="submit"
|
|
206
|
+
disabled={isSubmitting}
|
|
207
|
+
className="w-full px-6 py-3 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all font-medium hover:opacity-90"
|
|
208
|
+
style={{ backgroundColor: buttonColor }}
|
|
209
|
+
>
|
|
210
|
+
{isSubmitting ? 'Submitting...' : form.settings.submitButtonText}
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
</form>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
interface FormField {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
type: 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date' | 'photo' | 'image';
|
|
5
|
+
required: boolean;
|
|
6
|
+
options?: Array<{ value: string; label: string }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ValidationResult {
|
|
10
|
+
isValid: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FormValidationResult {
|
|
15
|
+
isValid: boolean;
|
|
16
|
+
errors: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateField(value: any, field: FormField): ValidationResult {
|
|
20
|
+
// Check required fields
|
|
21
|
+
if (field.required) {
|
|
22
|
+
if (value === undefined || value === null || value === '') {
|
|
23
|
+
return {
|
|
24
|
+
isValid: false,
|
|
25
|
+
error: `${field.label} is required`
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for empty arrays (checkboxes)
|
|
30
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
31
|
+
return {
|
|
32
|
+
isValid: false,
|
|
33
|
+
error: `Please select at least one ${field.label.toLowerCase()}`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check for photo upload
|
|
38
|
+
if (field.type === 'photo' && !value.data) {
|
|
39
|
+
return {
|
|
40
|
+
isValid: false,
|
|
41
|
+
error: `${field.label} is required`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Email validation
|
|
47
|
+
if (field.type === 'email' && value) {
|
|
48
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
49
|
+
if (!emailRegex.test(value)) {
|
|
50
|
+
return {
|
|
51
|
+
isValid: false,
|
|
52
|
+
error: 'Please enter a valid email address'
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Phone validation (basic)
|
|
58
|
+
if (field.type === 'tel' && value) {
|
|
59
|
+
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
|
|
60
|
+
if (!phoneRegex.test(value) || value.replace(/\D/g, '').length < 10) {
|
|
61
|
+
return {
|
|
62
|
+
isValid: false,
|
|
63
|
+
error: 'Please enter a valid phone number'
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Date validation
|
|
69
|
+
if (field.type === 'date' && value) {
|
|
70
|
+
const date = new Date(value);
|
|
71
|
+
if (isNaN(date.getTime())) {
|
|
72
|
+
return {
|
|
73
|
+
isValid: false,
|
|
74
|
+
error: 'Please enter a valid date'
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { isValid: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function validateForm(
|
|
83
|
+
formData: Record<string, any>,
|
|
84
|
+
fields: FormField[]
|
|
85
|
+
): FormValidationResult {
|
|
86
|
+
const errors: Record<string, string> = {};
|
|
87
|
+
let isValid = true;
|
|
88
|
+
|
|
89
|
+
fields.forEach(field => {
|
|
90
|
+
const validation = validateField(formData[field.id], field);
|
|
91
|
+
if (!validation.isValid && validation.error) {
|
|
92
|
+
errors[field.id] = validation.error;
|
|
93
|
+
isValid = false;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return { isValid, errors };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function sanitizeFormData(formData: Record<string, any>): Record<string, any> {
|
|
101
|
+
const sanitized: Record<string, any> = {};
|
|
102
|
+
|
|
103
|
+
Object.keys(formData).forEach(key => {
|
|
104
|
+
const value = formData[key];
|
|
105
|
+
|
|
106
|
+
// Handle different types
|
|
107
|
+
if (typeof value === 'string') {
|
|
108
|
+
// Trim whitespace
|
|
109
|
+
sanitized[key] = value.trim();
|
|
110
|
+
} else if (Array.isArray(value)) {
|
|
111
|
+
// Keep arrays as-is (for checkboxes)
|
|
112
|
+
sanitized[key] = value;
|
|
113
|
+
} else if (value && typeof value === 'object' && value.data) {
|
|
114
|
+
// Keep file data as-is (for photo uploads)
|
|
115
|
+
sanitized[key] = value;
|
|
116
|
+
} else {
|
|
117
|
+
sanitized[key] = value;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return sanitized;
|
|
122
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import type { HoldStatus, HoldTimerConfig } from '../../types';
|
|
5
|
+
|
|
6
|
+
export interface HoldTimerProps {
|
|
7
|
+
/** Current hold status */
|
|
8
|
+
holdStatus: HoldStatus | null;
|
|
9
|
+
/** Configuration for timer behavior */
|
|
10
|
+
config?: Partial<HoldTimerConfig>;
|
|
11
|
+
/** Theme colors */
|
|
12
|
+
colors: {
|
|
13
|
+
primary: string;
|
|
14
|
+
secondary: string;
|
|
15
|
+
text?: string;
|
|
16
|
+
};
|
|
17
|
+
/** Callback when user clicks extend button */
|
|
18
|
+
onExtend?: () => void | Promise<void>;
|
|
19
|
+
/** Callback when user clicks release button */
|
|
20
|
+
onRelease?: () => void | Promise<void>;
|
|
21
|
+
/** Callback when timer enters warning state */
|
|
22
|
+
onWarning?: (timeRemaining: number) => void;
|
|
23
|
+
/** Callback when timer expires */
|
|
24
|
+
onExpired?: () => void;
|
|
25
|
+
/** Additional CSS classes */
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_CONFIG: HoldTimerConfig = {
|
|
30
|
+
enabled: true,
|
|
31
|
+
duration: 600000, // 10 minutes
|
|
32
|
+
warningThreshold: 120000, // 2 minutes
|
|
33
|
+
maxExtensions: 2,
|
|
34
|
+
extensionDuration: 300000, // 5 minutes
|
|
35
|
+
showActions: true,
|
|
36
|
+
showWarning: true
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* HoldTimer - Countdown timer for time slot reservations
|
|
41
|
+
*
|
|
42
|
+
* Shows a countdown for how long a time slot is reserved, with
|
|
43
|
+
* visual warning states and optional extension/release actions.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```tsx
|
|
47
|
+
* <HoldTimer
|
|
48
|
+
* holdStatus={activeHold}
|
|
49
|
+
* config={{
|
|
50
|
+
* duration: 600000, // 10 minutes
|
|
51
|
+
* warningThreshold: 120000, // 2 minutes
|
|
52
|
+
* maxExtensions: 2
|
|
53
|
+
* }}
|
|
54
|
+
* colors={{ primary: '#8B5CF6', secondary: '#EC4899' }}
|
|
55
|
+
* onExtend={() => extendHold(activeHold.holdId)}
|
|
56
|
+
* onRelease={() => releaseHold(activeHold.holdId)}
|
|
57
|
+
* onExpired={() => {
|
|
58
|
+
* // Handle expiration - usually go back to time selection
|
|
59
|
+
* setSelectedTime(null);
|
|
60
|
+
* setStep('time');
|
|
61
|
+
* }}
|
|
62
|
+
* />
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function HoldTimer({
|
|
66
|
+
holdStatus,
|
|
67
|
+
config: userConfig,
|
|
68
|
+
colors,
|
|
69
|
+
onExtend,
|
|
70
|
+
onRelease,
|
|
71
|
+
onWarning,
|
|
72
|
+
onExpired,
|
|
73
|
+
className = ''
|
|
74
|
+
}: HoldTimerProps) {
|
|
75
|
+
const [currentTime, setCurrentTime] = useState(Date.now());
|
|
76
|
+
const [hasTriggeredWarning, setHasTriggeredWarning] = useState(false);
|
|
77
|
+
const [isExtending, setIsExtending] = useState(false);
|
|
78
|
+
const [isReleasing, setIsReleasing] = useState(false);
|
|
79
|
+
|
|
80
|
+
// Merge user config with defaults
|
|
81
|
+
const config: HoldTimerConfig = {
|
|
82
|
+
...DEFAULT_CONFIG,
|
|
83
|
+
...userConfig
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Update current time every second
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!holdStatus) return;
|
|
89
|
+
|
|
90
|
+
const interval = setInterval(() => {
|
|
91
|
+
setCurrentTime(Date.now());
|
|
92
|
+
}, 1000);
|
|
93
|
+
|
|
94
|
+
return () => clearInterval(interval);
|
|
95
|
+
}, [holdStatus]);
|
|
96
|
+
|
|
97
|
+
// Calculate remaining time
|
|
98
|
+
const timeRemaining = holdStatus ? Math.max(0, holdStatus.expiresAt - currentTime) : 0;
|
|
99
|
+
const isExpired = timeRemaining === 0 && holdStatus !== null;
|
|
100
|
+
const isWarning = timeRemaining <= config.warningThreshold && timeRemaining > 0;
|
|
101
|
+
|
|
102
|
+
// Trigger warning callback
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (isWarning && !hasTriggeredWarning && onWarning && holdStatus) {
|
|
105
|
+
setHasTriggeredWarning(true);
|
|
106
|
+
onWarning(timeRemaining);
|
|
107
|
+
}
|
|
108
|
+
}, [isWarning, hasTriggeredWarning, onWarning, holdStatus, timeRemaining]);
|
|
109
|
+
|
|
110
|
+
// Trigger expired callback
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (isExpired && holdStatus && onExpired) {
|
|
113
|
+
onExpired();
|
|
114
|
+
}
|
|
115
|
+
}, [isExpired, holdStatus, onExpired]);
|
|
116
|
+
|
|
117
|
+
// Reset warning trigger when hold changes
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
setHasTriggeredWarning(false);
|
|
120
|
+
}, [holdStatus?.holdKey]);
|
|
121
|
+
|
|
122
|
+
const handleExtend = useCallback(async () => {
|
|
123
|
+
if (!onExtend || !holdStatus || isExtending) return;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
setIsExtending(true);
|
|
127
|
+
await onExtend();
|
|
128
|
+
setHasTriggeredWarning(false); // Reset warning after extension
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('[HoldTimer] Failed to extend hold:', error);
|
|
131
|
+
} finally {
|
|
132
|
+
setIsExtending(false);
|
|
133
|
+
}
|
|
134
|
+
}, [onExtend, holdStatus, isExtending]);
|
|
135
|
+
|
|
136
|
+
const handleRelease = useCallback(async () => {
|
|
137
|
+
if (!onRelease || !holdStatus || isReleasing) return;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
setIsReleasing(true);
|
|
141
|
+
await onRelease();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error('[HoldTimer] Failed to release hold:', error);
|
|
144
|
+
} finally {
|
|
145
|
+
setIsReleasing(false);
|
|
146
|
+
}
|
|
147
|
+
}, [onRelease, holdStatus, isReleasing]);
|
|
148
|
+
|
|
149
|
+
// Don't render if no hold or not enabled
|
|
150
|
+
if (!holdStatus || !config.enabled) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Format time as MM:SS
|
|
155
|
+
const minutes = Math.floor(timeRemaining / 60000);
|
|
156
|
+
const seconds = Math.floor((timeRemaining % 60000) / 1000);
|
|
157
|
+
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
158
|
+
|
|
159
|
+
// Determine display variant
|
|
160
|
+
let bgColor = 'rgba(139, 92, 246, 0.1)'; // primary with opacity
|
|
161
|
+
let borderColor = 'rgba(139, 92, 246, 0.3)';
|
|
162
|
+
let textColor = colors.primary;
|
|
163
|
+
|
|
164
|
+
if (isExpired) {
|
|
165
|
+
bgColor = 'rgba(239, 68, 68, 0.1)';
|
|
166
|
+
borderColor = 'rgba(239, 68, 68, 0.3)';
|
|
167
|
+
textColor = '#EF4444';
|
|
168
|
+
} else if (isWarning && config.showWarning) {
|
|
169
|
+
bgColor = `${colors.secondary}1A`; // secondary with opacity (hex alpha)
|
|
170
|
+
borderColor = `${colors.secondary}4D`;
|
|
171
|
+
textColor = colors.secondary;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
className={`rounded-lg p-4 text-center ${className}`}
|
|
177
|
+
style={{
|
|
178
|
+
backgroundColor: bgColor,
|
|
179
|
+
borderWidth: '1px',
|
|
180
|
+
borderStyle: 'solid',
|
|
181
|
+
borderColor: borderColor
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
<div className="flex flex-col items-center justify-center">
|
|
185
|
+
{/* Timer Display */}
|
|
186
|
+
<div className="flex flex-col items-center gap-2 mb-4">
|
|
187
|
+
{isExpired ? (
|
|
188
|
+
<div style={{ color: textColor }} className="font-medium text-base">
|
|
189
|
+
<span>Time expired - </span>
|
|
190
|
+
<button
|
|
191
|
+
onClick={handleRelease}
|
|
192
|
+
disabled={isReleasing}
|
|
193
|
+
className="underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-offset-2 inline disabled:opacity-50"
|
|
194
|
+
style={{ color: textColor }}
|
|
195
|
+
>
|
|
196
|
+
{isReleasing ? 'Releasing...' : 'select a new time'}
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
) : (
|
|
200
|
+
<div style={{ color: textColor }} className="font-medium text-base">
|
|
201
|
+
Your time slot is reserved for {formattedTime}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* Actions */}
|
|
207
|
+
{config.showActions && !isExpired && (
|
|
208
|
+
<div className="flex justify-center gap-2">
|
|
209
|
+
{/* Extend Button */}
|
|
210
|
+
{onExtend && holdStatus.canExtend && (
|
|
211
|
+
<button
|
|
212
|
+
onClick={handleExtend}
|
|
213
|
+
disabled={!holdStatus.canExtend || isExtending}
|
|
214
|
+
className="px-4 py-2 text-sm font-medium rounded transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed text-white"
|
|
215
|
+
style={{ backgroundColor: colors.primary }}
|
|
216
|
+
aria-label={`Extend hold (${holdStatus.maxExtensions - holdStatus.extensions} extensions remaining)`}
|
|
217
|
+
>
|
|
218
|
+
{isExtending ? 'Extending...' : 'Extend'}
|
|
219
|
+
{!isExtending && holdStatus.maxExtensions - holdStatus.extensions > 0 && (
|
|
220
|
+
<span className="ml-1">
|
|
221
|
+
({holdStatus.maxExtensions - holdStatus.extensions})
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{/* Release Button */}
|
|
228
|
+
{onRelease && (
|
|
229
|
+
<button
|
|
230
|
+
onClick={handleRelease}
|
|
231
|
+
disabled={isReleasing}
|
|
232
|
+
className="px-4 py-2 text-sm font-medium rounded border transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
233
|
+
style={{
|
|
234
|
+
borderColor: 'rgba(255, 255, 255, 0.2)',
|
|
235
|
+
color: colors.text || '#000000'
|
|
236
|
+
}}
|
|
237
|
+
aria-label="Release hold and return to time selection"
|
|
238
|
+
>
|
|
239
|
+
{isReleasing ? 'Releasing...' : 'Release'}
|
|
240
|
+
</button>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{/* Extension Limit Reached */}
|
|
246
|
+
{!isExpired && !holdStatus.canExtend && holdStatus.extensions >= holdStatus.maxExtensions && (
|
|
247
|
+
<div className="mt-3 pt-3 border-t border-white/10 text-center">
|
|
248
|
+
<div className="text-sm" style={{ color: colors.secondary }}>
|
|
249
|
+
Maximum extensions reached - complete booking now or{' '}
|
|
250
|
+
<button
|
|
251
|
+
onClick={handleRelease}
|
|
252
|
+
disabled={isReleasing}
|
|
253
|
+
className="hover:underline focus:outline-none focus:underline disabled:opacity-50"
|
|
254
|
+
style={{ color: colors.primary }}
|
|
255
|
+
>
|
|
256
|
+
{isReleasing ? 'releasing...' : 'select a different time'}
|
|
257
|
+
</button>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export default HoldTimer;
|