@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,283 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import FormField from './FormField';
5
+ import {
6
+ DndContext,
7
+ closestCenter,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ DragEndEvent,
12
+ } from '@dnd-kit/core';
13
+ import {
14
+ arrayMove,
15
+ SortableContext,
16
+ verticalListSortingStrategy,
17
+ useSortable,
18
+ } from '@dnd-kit/sortable';
19
+ import { CSS } from '@dnd-kit/utilities';
20
+ import { GripVertical } from 'lucide-react';
21
+
22
+ // Sortable wrapper for form fields in preview mode
23
+ function SortableFormField({ field, value, error, onChange, businessId, formId, styling }: any) {
24
+ const {
25
+ attributes,
26
+ listeners,
27
+ setNodeRef,
28
+ transform,
29
+ transition,
30
+ isDragging,
31
+ } = useSortable({ id: field.id });
32
+
33
+ const style = {
34
+ transform: CSS.Transform.toString(transform),
35
+ transition,
36
+ opacity: isDragging ? 0.5 : 1,
37
+ };
38
+
39
+ return (
40
+ <div
41
+ ref={setNodeRef}
42
+ style={style}
43
+ className={`relative group ${isDragging ? 'z-50' : ''}`}
44
+ >
45
+ {/* Drag Handle */}
46
+ <div
47
+ {...attributes}
48
+ {...listeners}
49
+ className="absolute -left-8 top-1/2 -translate-y-1/2 z-50 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
50
+ onClick={(e) => e.stopPropagation()}
51
+ >
52
+ <div className="flex items-center gap-1 bg-[#BCB4FF] text-black px-1.5 py-1 rounded-md shadow-lg border border-black/10">
53
+ <GripVertical className="w-3 h-3" />
54
+ </div>
55
+ </div>
56
+
57
+ {/* Hover Outline */}
58
+ <div className="absolute inset-0 pointer-events-none transition-all rounded-lg group-hover:border border-[#BCB4FF]/30" />
59
+
60
+ {/* Field Content - ensure inputs can receive clicks */}
61
+ <div style={{ pointerEvents: 'auto' }}>
62
+ <FormField
63
+ field={field}
64
+ value={value}
65
+ error={error}
66
+ onChange={onChange}
67
+ businessId={businessId}
68
+ formId={formId}
69
+ styling={styling}
70
+ />
71
+ </div>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ interface FormFieldData {
77
+ id: string;
78
+ label: string;
79
+ type: 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date' | 'photo' | 'image';
80
+ placeholder?: string;
81
+ required: boolean;
82
+ options?: Array<{ value: string; label: string }>;
83
+ }
84
+
85
+ interface FormBlockData {
86
+ id: string;
87
+ type: 'section' | 'text' | 'image';
88
+ title?: string;
89
+ description?: string;
90
+ content?: string;
91
+ alignment?: 'left' | 'center' | 'right';
92
+ imageUrl?: string;
93
+ fields?: FormFieldData[];
94
+ }
95
+
96
+ interface FormBlockProps {
97
+ block: FormBlockData;
98
+ formData: Record<string, any>;
99
+ errors: Record<string, string>;
100
+ onChange: (fieldId: string, value: any, field: FormFieldData) => void;
101
+ businessId?: string;
102
+ formId?: string;
103
+ styling?: {
104
+ alignment?: 'left' | 'center' | 'right';
105
+ fieldStyle?: 'outlined' | 'filled' | 'underlined' | 'minimal';
106
+ sectionBackground?: string;
107
+ fieldBorder?: string;
108
+ buttonColor?: string;
109
+ labelColor?: string;
110
+ textColor?: string;
111
+ };
112
+ previewMode?: boolean;
113
+ onFieldReorder?: (blockId: string, oldIndex: number, newIndex: number) => void;
114
+ }
115
+
116
+ export default function FormBlock({ block, formData, errors, onChange, businessId, formId, styling, previewMode, onFieldReorder }: FormBlockProps) {
117
+ // Setup sensors for drag detection (only used in preview mode)
118
+ const sensors = useSensors(
119
+ useSensor(PointerSensor, {
120
+ activationConstraint: {
121
+ distance: 8, // Require 8px movement before drag starts
122
+ },
123
+ })
124
+ );
125
+
126
+ // Handle field drag end
127
+ const handleFieldDragEnd = (event: DragEndEvent) => {
128
+ const { active, over } = event;
129
+
130
+ if (over && active.id !== over.id && onFieldReorder && block.fields) {
131
+ const oldIndex = block.fields.findIndex((f: any) => f.id === active.id);
132
+ const newIndex = block.fields.findIndex((f: any) => f.id === over.id);
133
+
134
+ onFieldReorder(block.id, oldIndex, newIndex);
135
+ }
136
+ };
137
+
138
+ // Section Block - Contains form fields
139
+ if (block.type === 'section') {
140
+ const fields = block.fields || [];
141
+
142
+ // In preview mode with field reordering, use drag-and-drop
143
+ if (previewMode && onFieldReorder) {
144
+ return (
145
+ <div
146
+ className="border rounded-lg p-6"
147
+ style={{
148
+ backgroundColor: styling?.sectionBackground || '#FFFFFF',
149
+ borderColor: styling?.fieldBorder ? `${styling.fieldBorder}30` : '#E5E7EB'
150
+ }}
151
+ >
152
+ {block.title && (
153
+ <h2 className="text-xl font-semibold mb-2" style={{ color: styling?.labelColor || '#000000' }}>{block.title}</h2>
154
+ )}
155
+ {block.description && (
156
+ <p className="text-sm mb-6" style={{ color: styling?.textColor || '#000000' }}>{block.description}</p>
157
+ )}
158
+
159
+ <DndContext
160
+ sensors={sensors}
161
+ collisionDetection={closestCenter}
162
+ onDragEnd={handleFieldDragEnd}
163
+ >
164
+ <SortableContext
165
+ items={fields.map((f: any) => f.id)}
166
+ strategy={verticalListSortingStrategy}
167
+ >
168
+ <div className="space-y-4">
169
+ {fields.map((field) => (
170
+ <SortableFormField
171
+ key={field.id}
172
+ field={field}
173
+ value={formData[field.id]}
174
+ error={errors[field.id]}
175
+ onChange={(value: any) => onChange(field.id, value, field)}
176
+ businessId={businessId}
177
+ formId={formId}
178
+ styling={styling}
179
+ />
180
+ ))}
181
+ </div>
182
+ </SortableContext>
183
+ </DndContext>
184
+ </div>
185
+ );
186
+ }
187
+
188
+ // In normal mode, render without drag-and-drop
189
+ return (
190
+ <div
191
+ className="border rounded-lg p-6"
192
+ style={{
193
+ backgroundColor: styling?.sectionBackground || '#FFFFFF',
194
+ borderColor: styling?.fieldBorder ? `${styling.fieldBorder}30` : '#E5E7EB'
195
+ }}
196
+ >
197
+ {block.title && (
198
+ <h2 className="text-xl font-semibold mb-2" style={{ color: styling?.labelColor || '#000000' }}>{block.title}</h2>
199
+ )}
200
+ {block.description && (
201
+ <p className="text-sm mb-6" style={{ color: styling?.textColor || '#000000' }}>{block.description}</p>
202
+ )}
203
+
204
+ <div className="space-y-4">
205
+ {fields.map((field) => (
206
+ <FormField
207
+ key={field.id}
208
+ field={field}
209
+ value={formData[field.id]}
210
+ error={errors[field.id]}
211
+ onChange={(value) => onChange(field.id, value, field)}
212
+ businessId={businessId}
213
+ formId={formId}
214
+ styling={styling}
215
+ />
216
+ ))}
217
+ </div>
218
+ </div>
219
+ );
220
+ }
221
+
222
+ // Text Block - Rich text content
223
+ if (block.type === 'text') {
224
+ const alignmentClass = {
225
+ left: 'text-left',
226
+ center: 'text-center',
227
+ right: 'text-right'
228
+ }[block.alignment || 'left'];
229
+
230
+ return (
231
+ <div className={`prose prose-gray max-w-none ${alignmentClass}`}>
232
+ <p className="whitespace-pre-wrap" style={{ color: styling?.textColor || '#000000' }}>{block.content}</p>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ // Image Block - Styled image
238
+ if (block.type === 'image' && block.imageUrl) {
239
+ const imageSize = (block as any).imageSize || 'medium';
240
+ const isFull = imageSize === 'full';
241
+
242
+ const alignmentClass = isFull ? '' : {
243
+ left: 'mr-auto',
244
+ center: 'mx-auto',
245
+ right: 'ml-auto'
246
+ }[block.alignment || 'center'];
247
+
248
+ const sizeMap = {
249
+ small: '300px',
250
+ medium: '500px',
251
+ large: '700px'
252
+ };
253
+ const maxWidth = isFull ? 'none' : (sizeMap[imageSize as keyof typeof sizeMap] || sizeMap.medium);
254
+
255
+ const borderRadiusMap = {
256
+ none: '0',
257
+ sm: '0.25rem',
258
+ md: '0.375rem',
259
+ lg: '0.5rem',
260
+ xl: '0.75rem',
261
+ full: '9999px'
262
+ };
263
+ const borderRadius = borderRadiusMap[(block as any).borderRadius as keyof typeof borderRadiusMap] || borderRadiusMap.lg;
264
+
265
+ const containerStyle: React.CSSProperties = {
266
+ maxWidth,
267
+ ...(isFull && { width: '100vw', marginLeft: '50%', transform: 'translateX(-50%)' })
268
+ };
269
+
270
+ return (
271
+ <div className={alignmentClass} style={containerStyle}>
272
+ <img
273
+ src={block.imageUrl}
274
+ alt=""
275
+ className="w-full h-auto"
276
+ style={{ borderRadius, objectFit: 'cover' }}
277
+ />
278
+ </div>
279
+ );
280
+ }
281
+
282
+ return null;
283
+ }
@@ -0,0 +1,385 @@
1
+ 'use client';
2
+
3
+ import React, { useRef, useState } from 'react';
4
+ import { uploadFormImage, validateImageFile } from '../../lib/storage';
5
+
6
+ interface FormFieldData {
7
+ id: string;
8
+ label: string;
9
+ type: 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date' | 'photo' | 'image';
10
+ placeholder?: string;
11
+ required: boolean;
12
+ options?: Array<{ value: string; label: string }>;
13
+ }
14
+
15
+ interface FormFieldProps {
16
+ field: FormFieldData;
17
+ value: any;
18
+ error?: string;
19
+ onChange: (value: any) => void;
20
+ businessId?: string;
21
+ formId?: string;
22
+ styling?: {
23
+ fieldStyle?: 'outlined' | 'filled' | 'underlined' | 'minimal';
24
+ sectionBackground?: string;
25
+ fieldBorder?: string;
26
+ labelColor?: string;
27
+ textColor?: string;
28
+ buttonColor?: string;
29
+ };
30
+ }
31
+
32
+ export default function FormField({ field, value, error, onChange, businessId, formId, styling }: FormFieldProps) {
33
+ const fileInputRef = useRef<HTMLInputElement>(null);
34
+ const [uploading, setUploading] = useState(false);
35
+ const [uploadError, setUploadError] = useState<string | null>(null);
36
+
37
+ const labelColor = styling?.labelColor || '#374151';
38
+ const fieldStyle = styling?.fieldStyle || 'outlined';
39
+
40
+ // Build input classes based on field style
41
+ const getInputClasses = () => {
42
+ const baseClasses = 'w-full px-4 py-2 transition-all focus:outline-none';
43
+
44
+ if (error) {
45
+ return `${baseClasses} bg-red-50 border border-red-300 rounded-lg focus:ring-2 focus:ring-red-500`;
46
+ }
47
+
48
+ switch (fieldStyle) {
49
+ case 'filled':
50
+ return `${baseClasses} bg-gray-100 border border-transparent rounded-lg focus:bg-white focus:ring-2 focus:ring-gray-900 focus:border-transparent`;
51
+ case 'underlined':
52
+ return `${baseClasses} bg-transparent border-0 border-b-2 border-gray-300 rounded-none focus:border-gray-900 focus:ring-0 px-0`;
53
+ case 'minimal':
54
+ return `${baseClasses} bg-transparent border border-gray-200 rounded-lg focus:border-gray-400 focus:ring-1 focus:ring-gray-300`;
55
+ case 'outlined':
56
+ default:
57
+ return `${baseClasses} bg-white border-2 border-gray-300 rounded-lg focus:border-gray-900 focus:ring-2 focus:ring-gray-900`;
58
+ }
59
+ };
60
+
61
+ const baseInputClass = getInputClasses();
62
+ const labelClass = `block text-sm font-medium mb-1`;
63
+
64
+ // Text input
65
+ if (field.type === 'text' || field.type === 'email' || field.type === 'tel') {
66
+ return (
67
+ <div>
68
+ <label htmlFor={field.id} className={labelClass} style={{ color: labelColor }}>
69
+ {field.label}
70
+ {field.required && <span className="text-red-500 ml-1">*</span>}
71
+ </label>
72
+ <input
73
+ type={field.type}
74
+ id={field.id}
75
+ name={field.id}
76
+ value={value || ''}
77
+ onChange={(e) => onChange(e.target.value)}
78
+ placeholder={field.placeholder}
79
+ required={field.required}
80
+ className={baseInputClass}
81
+ style={{ color: labelColor }}
82
+ />
83
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ // Textarea
89
+ if (field.type === 'textarea') {
90
+ return (
91
+ <div>
92
+ <label htmlFor={field.id} className={labelClass} style={{ color: labelColor }}>
93
+ {field.label}
94
+ {field.required && <span className="text-red-500 ml-1">*</span>}
95
+ </label>
96
+ <textarea
97
+ id={field.id}
98
+ name={field.id}
99
+ value={value || ''}
100
+ onChange={(e) => onChange(e.target.value)}
101
+ placeholder={field.placeholder}
102
+ required={field.required}
103
+ rows={4}
104
+ className={baseInputClass}
105
+ style={{ color: labelColor }}
106
+ />
107
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ // Date input
113
+ if (field.type === 'date') {
114
+ return (
115
+ <div>
116
+ <label htmlFor={field.id} className={labelClass} style={{ color: labelColor }}>
117
+ {field.label}
118
+ {field.required && <span className="text-red-500 ml-1">*</span>}
119
+ </label>
120
+ <input
121
+ type="date"
122
+ id={field.id}
123
+ name={field.id}
124
+ value={value || ''}
125
+ onChange={(e) => onChange(e.target.value)}
126
+ required={field.required}
127
+ className={baseInputClass}
128
+ style={{ color: labelColor }}
129
+ />
130
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ // Select dropdown
136
+ if (field.type === 'select' && field.options) {
137
+ return (
138
+ <div>
139
+ <label htmlFor={field.id} className={labelClass} style={{ color: labelColor }}>
140
+ {field.label}
141
+ {field.required && <span className="text-red-500 ml-1">*</span>}
142
+ </label>
143
+ <select
144
+ id={field.id}
145
+ name={field.id}
146
+ value={value || ''}
147
+ onChange={(e) => onChange(e.target.value)}
148
+ required={field.required}
149
+ className={baseInputClass}
150
+ style={{ color: labelColor }}
151
+ >
152
+ <option value="">Select an option...</option>
153
+ {field.options.map((option) => (
154
+ <option key={option.value} value={option.value}>
155
+ {option.label}
156
+ </option>
157
+ ))}
158
+ </select>
159
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ // Radio buttons
165
+ if (field.type === 'radio' && field.options) {
166
+ return (
167
+ <div>
168
+ <label className={labelClass} style={{ color: labelColor }}>
169
+ {field.label}
170
+ {field.required && <span className="text-red-500 ml-1">*</span>}
171
+ </label>
172
+ <div className="space-y-2 mt-2">
173
+ {field.options.map((option) => (
174
+ <label
175
+ key={option.value}
176
+ className="flex items-center space-x-2 cursor-pointer"
177
+ >
178
+ <input
179
+ type="radio"
180
+ name={field.id}
181
+ value={option.value}
182
+ checked={value === option.value}
183
+ onChange={(e) => onChange(e.target.value)}
184
+ required={field.required}
185
+ className="w-4 h-4 text-gray-900 focus:ring-gray-900 focus:ring-2"
186
+ />
187
+ <span className="text-sm" style={{ color: styling?.labelColor || '#000000' }}>{option.label}</span>
188
+ </label>
189
+ ))}
190
+ </div>
191
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
192
+ </div>
193
+ );
194
+ }
195
+
196
+ // Checkboxes (multiple selection)
197
+ if (field.type === 'checkbox' && field.options) {
198
+ const selectedValues = Array.isArray(value) ? value : [];
199
+
200
+ const handleCheckboxChange = (optionValue: string) => {
201
+ const newValues = selectedValues.includes(optionValue)
202
+ ? selectedValues.filter((v) => v !== optionValue)
203
+ : [...selectedValues, optionValue];
204
+ onChange(newValues);
205
+ };
206
+
207
+ return (
208
+ <div>
209
+ <label className={labelClass} style={{ color: labelColor }}>
210
+ {field.label}
211
+ {field.required && <span className="text-red-500 ml-1">*</span>}
212
+ </label>
213
+ <div className="space-y-2 mt-2">
214
+ {field.options.map((option) => (
215
+ <label
216
+ key={option.value}
217
+ className="flex items-center space-x-2 cursor-pointer"
218
+ >
219
+ <input
220
+ type="checkbox"
221
+ name={`${field.id}[]`}
222
+ value={option.value}
223
+ checked={selectedValues.includes(option.value)}
224
+ onChange={() => handleCheckboxChange(option.value)}
225
+ className="w-4 h-4 text-gray-900 focus:ring-gray-900 focus:ring-2 rounded"
226
+ />
227
+ <span className="text-sm" style={{ color: styling?.labelColor || '#000000' }}>{option.label}</span>
228
+ </label>
229
+ ))}
230
+ </div>
231
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
232
+ </div>
233
+ );
234
+ }
235
+
236
+ // Photo upload
237
+ if (field.type === 'photo') {
238
+ const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
239
+ const file = e.target.files?.[0];
240
+ if (!file) return;
241
+
242
+ setUploadError(null);
243
+
244
+ // Validate file
245
+ const validation = validateImageFile(file);
246
+ if (!validation.valid) {
247
+ setUploadError(validation.error || 'Invalid file');
248
+ if (fileInputRef.current) {
249
+ fileInputRef.current.value = '';
250
+ }
251
+ return;
252
+ }
253
+
254
+ // Check if businessId and formId are provided
255
+ if (!businessId || !formId) {
256
+ setUploadError('Missing required configuration for file upload');
257
+ if (fileInputRef.current) {
258
+ fileInputRef.current.value = '';
259
+ }
260
+ return;
261
+ }
262
+
263
+ // Upload to Supabase Storage
264
+ setUploading(true);
265
+ try {
266
+ const uploaded = await uploadFormImage(file, businessId, formId);
267
+ onChange({
268
+ url: uploaded.url,
269
+ filename: uploaded.filename,
270
+ size: uploaded.size,
271
+ type: uploaded.type,
272
+ uploadedAt: new Date().toISOString()
273
+ });
274
+ setUploadError(null);
275
+ } catch (err) {
276
+ console.error('Photo upload error:', err);
277
+ setUploadError(err instanceof Error ? err.message : 'Upload failed');
278
+ if (fileInputRef.current) {
279
+ fileInputRef.current.value = '';
280
+ }
281
+ } finally {
282
+ setUploading(false);
283
+ }
284
+ };
285
+
286
+ return (
287
+ <div>
288
+ <label htmlFor={field.id} className={labelClass} style={{ color: labelColor }}>
289
+ {field.label}
290
+ {field.required && <span className="text-red-500 ml-1">*</span>}
291
+ </label>
292
+ <div className="mt-1">
293
+ <input
294
+ ref={fileInputRef}
295
+ type="file"
296
+ id={field.id}
297
+ name={field.id}
298
+ accept="image/*"
299
+ onChange={handleFileChange}
300
+ required={field.required}
301
+ disabled={uploading}
302
+ className="block w-full text-sm text-gray-500
303
+ file:mr-4 file:py-2 file:px-4
304
+ file:rounded-lg file:border-0
305
+ file:text-sm file:font-medium
306
+ file:bg-gray-900 file:text-white
307
+ hover:file:bg-gray-800
308
+ file:cursor-pointer cursor-pointer
309
+ disabled:opacity-50 disabled:cursor-not-allowed"
310
+ />
311
+ {uploading && (
312
+ <div className="mt-2 flex items-center gap-2 text-xs text-blue-600">
313
+ <div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent"></div>
314
+ <span>Uploading...</span>
315
+ </div>
316
+ )}
317
+ {value && !uploading && (
318
+ <div className="mt-2 flex items-center gap-2">
319
+ <svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
320
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
321
+ </svg>
322
+ <span className="text-xs text-gray-600">
323
+ {value.filename} ({Math.round(value.size / 1024)}KB)
324
+ </span>
325
+ </div>
326
+ )}
327
+ </div>
328
+ <p className="mt-1 text-xs text-gray-500">Max file size: 5MB • JPG, PNG, WebP</p>
329
+ {uploadError && <p className="mt-1 text-xs text-red-600">{uploadError}</p>}
330
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
331
+ </div>
332
+ );
333
+ }
334
+
335
+ // Decorative image (within section)
336
+ if (field.type === 'image') {
337
+ const fieldData = field as any;
338
+ const imageUrl = fieldData.imageUrl;
339
+
340
+ if (!imageUrl) return null;
341
+
342
+ const imageSize = fieldData.imageSize || 'medium';
343
+ const isFull = imageSize === 'full';
344
+
345
+ const sizeMap = {
346
+ small: '200px',
347
+ medium: '400px',
348
+ large: '600px'
349
+ };
350
+ const maxWidth = isFull ? 'none' : (sizeMap[imageSize as keyof typeof sizeMap] || sizeMap.medium);
351
+
352
+ const borderRadiusMap = {
353
+ none: '0',
354
+ sm: '0.25rem',
355
+ md: '0.375rem',
356
+ lg: '0.5rem',
357
+ full: '9999px'
358
+ };
359
+ const borderRadius = borderRadiusMap[fieldData.borderRadius as keyof typeof borderRadiusMap] || borderRadiusMap.md;
360
+
361
+ const alignmentClass = isFull ? '' : {
362
+ left: 'mr-auto',
363
+ center: 'mx-auto',
364
+ right: 'ml-auto'
365
+ }[fieldData.imageAlignment || 'center'];
366
+
367
+ const containerStyle: React.CSSProperties = {
368
+ maxWidth,
369
+ ...(isFull && { width: '100%' })
370
+ };
371
+
372
+ return (
373
+ <div className={alignmentClass} style={containerStyle}>
374
+ <img
375
+ src={imageUrl}
376
+ alt={field.label || ''}
377
+ className="w-full h-auto"
378
+ style={{ borderRadius }}
379
+ />
380
+ </div>
381
+ );
382
+ }
383
+
384
+ return null;
385
+ }