@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,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
|
+
}
|