@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,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,4 @@
1
+ export { default as FormRenderer } from './FormRenderer';
2
+ export { default as FormBlock } from './FormBlock';
3
+ // FormField is internal to FormBlock and FormRenderer, no need to export
4
+ export * from './FormValidation';
@@ -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;
@@ -0,0 +1,2 @@
1
+ export { HoldTimer } from './HoldTimer';
2
+ export type { HoldTimerProps } from './HoldTimer';