@bookinglab/booking-ui-react 1.0.2 → 1.1.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 +128 -0
- package/dist/index.d.cts +136 -1
- package/dist/index.d.ts +136 -1
- package/dist/index.js +330 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +644 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +6 -6
- package/dist/index.cjs +0 -360
- package/dist/index.cjs.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { forwardRef, useId, useState, useCallback, useImperativeHandle, useEffect } from 'react';
|
|
2
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
// src/components/BookingForm.tsx
|
|
5
|
+
var cx = (...classes) => classes.filter(Boolean).join(" ");
|
|
6
|
+
function FormField({
|
|
7
|
+
question,
|
|
8
|
+
value,
|
|
9
|
+
error,
|
|
10
|
+
onChange,
|
|
11
|
+
classNames
|
|
12
|
+
}) {
|
|
13
|
+
const inputId = `question-${question.id}`;
|
|
14
|
+
const errorId = `${inputId}-error`;
|
|
15
|
+
const hasError = !!error;
|
|
16
|
+
const defaultFieldWrapper = "mb-4";
|
|
17
|
+
const defaultLabel = "block text-sm font-medium mb-1 text-gray-700";
|
|
18
|
+
const defaultHeading = "text-lg font-semibold text-gray-900 mt-4 mb-2 first:mt-0";
|
|
19
|
+
const defaultInput = "w-full px-3 py-2 border rounded-md text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1 border-gray-300 focus:ring-blue-500 focus:border-blue-500";
|
|
20
|
+
const defaultInputError = "border-red-500 focus:ring-red-500";
|
|
21
|
+
const defaultCheckbox = "mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500";
|
|
22
|
+
const defaultHelpText = "mt-1 text-xs text-gray-500";
|
|
23
|
+
const defaultErrorText = "mt-1 text-xs text-red-600";
|
|
24
|
+
const inputClasses = hasError ? cx(classNames?.input ?? defaultInput, classNames?.inputError ?? defaultInputError) : cx(classNames?.input ?? defaultInput);
|
|
25
|
+
const labelClasses = classNames?.label ?? defaultLabel;
|
|
26
|
+
const checkboxLabelClasses = cx("text-sm", classNames?.label ?? "text-gray-700");
|
|
27
|
+
const renderLabel = () => {
|
|
28
|
+
if (question.detail_type === "heading") return null;
|
|
29
|
+
return /* @__PURE__ */ jsxs("label", { htmlFor: inputId, className: labelClasses, children: [
|
|
30
|
+
question.name,
|
|
31
|
+
question.required && /* @__PURE__ */ jsx("span", { className: "text-red-500 ml-1", "aria-hidden": "true", children: "*" })
|
|
32
|
+
] });
|
|
33
|
+
};
|
|
34
|
+
const renderHelpText = () => {
|
|
35
|
+
if (!question.help_text) return null;
|
|
36
|
+
return /* @__PURE__ */ jsx("p", { className: classNames?.helpText ?? defaultHelpText, children: question.help_text });
|
|
37
|
+
};
|
|
38
|
+
const renderError = () => {
|
|
39
|
+
if (!error) return null;
|
|
40
|
+
return /* @__PURE__ */ jsx("p", { id: errorId, className: classNames?.errorText ?? defaultErrorText, role: "alert", children: error });
|
|
41
|
+
};
|
|
42
|
+
const ariaProps = {
|
|
43
|
+
"aria-invalid": hasError ? true : void 0,
|
|
44
|
+
"aria-describedby": hasError ? errorId : void 0,
|
|
45
|
+
"aria-required": question.required || void 0
|
|
46
|
+
};
|
|
47
|
+
switch (question.detail_type) {
|
|
48
|
+
case "heading":
|
|
49
|
+
return /* @__PURE__ */ jsx("h3", { className: classNames?.heading ?? defaultHeading, children: question.name });
|
|
50
|
+
case "text_field":
|
|
51
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames?.fieldWrapper ?? defaultFieldWrapper, children: [
|
|
52
|
+
renderLabel(),
|
|
53
|
+
/* @__PURE__ */ jsx(
|
|
54
|
+
"input",
|
|
55
|
+
{
|
|
56
|
+
id: inputId,
|
|
57
|
+
type: "text",
|
|
58
|
+
value: value || "",
|
|
59
|
+
onChange: (e) => onChange(e.target.value),
|
|
60
|
+
placeholder: question.settings?.placeholder,
|
|
61
|
+
className: inputClasses,
|
|
62
|
+
...ariaProps
|
|
63
|
+
}
|
|
64
|
+
),
|
|
65
|
+
renderHelpText(),
|
|
66
|
+
renderError()
|
|
67
|
+
] });
|
|
68
|
+
case "text_area":
|
|
69
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames?.fieldWrapper ?? defaultFieldWrapper, children: [
|
|
70
|
+
renderLabel(),
|
|
71
|
+
/* @__PURE__ */ jsx(
|
|
72
|
+
"textarea",
|
|
73
|
+
{
|
|
74
|
+
id: inputId,
|
|
75
|
+
value: value || "",
|
|
76
|
+
onChange: (e) => onChange(e.target.value),
|
|
77
|
+
placeholder: question.settings?.placeholder,
|
|
78
|
+
rows: 4,
|
|
79
|
+
className: inputClasses,
|
|
80
|
+
...ariaProps
|
|
81
|
+
}
|
|
82
|
+
),
|
|
83
|
+
renderHelpText(),
|
|
84
|
+
renderError()
|
|
85
|
+
] });
|
|
86
|
+
case "select":
|
|
87
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames?.fieldWrapper ?? defaultFieldWrapper, children: [
|
|
88
|
+
renderLabel(),
|
|
89
|
+
/* @__PURE__ */ jsxs(
|
|
90
|
+
"select",
|
|
91
|
+
{
|
|
92
|
+
id: inputId,
|
|
93
|
+
value: value ?? "",
|
|
94
|
+
onChange: (e) => onChange(e.target.value),
|
|
95
|
+
className: inputClasses,
|
|
96
|
+
...ariaProps,
|
|
97
|
+
children: [
|
|
98
|
+
/* @__PURE__ */ jsx("option", { value: "", children: "Select an option" }),
|
|
99
|
+
question.options?.map((option) => /* @__PURE__ */ jsx("option", { value: option.id, children: option.name }, option.id))
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
),
|
|
103
|
+
renderHelpText(),
|
|
104
|
+
renderError()
|
|
105
|
+
] });
|
|
106
|
+
case "date":
|
|
107
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames?.fieldWrapper ?? defaultFieldWrapper, children: [
|
|
108
|
+
renderLabel(),
|
|
109
|
+
/* @__PURE__ */ jsx(
|
|
110
|
+
"input",
|
|
111
|
+
{
|
|
112
|
+
id: inputId,
|
|
113
|
+
type: "date",
|
|
114
|
+
value: value || "",
|
|
115
|
+
onChange: (e) => onChange(e.target.value),
|
|
116
|
+
min: question.settings?.min?.toString(),
|
|
117
|
+
max: question.settings?.max?.toString(),
|
|
118
|
+
className: inputClasses,
|
|
119
|
+
...ariaProps
|
|
120
|
+
}
|
|
121
|
+
),
|
|
122
|
+
renderHelpText(),
|
|
123
|
+
renderError()
|
|
124
|
+
] });
|
|
125
|
+
case "number":
|
|
126
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames?.fieldWrapper ?? defaultFieldWrapper, children: [
|
|
127
|
+
renderLabel(),
|
|
128
|
+
/* @__PURE__ */ jsx(
|
|
129
|
+
"input",
|
|
130
|
+
{
|
|
131
|
+
id: inputId,
|
|
132
|
+
type: "number",
|
|
133
|
+
value: value ?? "",
|
|
134
|
+
onChange: (e) => onChange(e.target.value ? Number(e.target.value) : ""),
|
|
135
|
+
min: question.settings?.min,
|
|
136
|
+
max: question.settings?.max,
|
|
137
|
+
placeholder: question.settings?.placeholder,
|
|
138
|
+
className: inputClasses,
|
|
139
|
+
...ariaProps
|
|
140
|
+
}
|
|
141
|
+
),
|
|
142
|
+
renderHelpText(),
|
|
143
|
+
renderError()
|
|
144
|
+
] });
|
|
145
|
+
case "check":
|
|
146
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames?.fieldWrapper ?? defaultFieldWrapper, children: [
|
|
147
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-start gap-2", children: [
|
|
148
|
+
/* @__PURE__ */ jsx(
|
|
149
|
+
"input",
|
|
150
|
+
{
|
|
151
|
+
id: inputId,
|
|
152
|
+
type: "checkbox",
|
|
153
|
+
checked: !!value,
|
|
154
|
+
onChange: (e) => onChange(e.target.checked),
|
|
155
|
+
className: classNames?.checkbox ?? defaultCheckbox,
|
|
156
|
+
...ariaProps
|
|
157
|
+
}
|
|
158
|
+
),
|
|
159
|
+
/* @__PURE__ */ jsxs("label", { htmlFor: inputId, className: checkboxLabelClasses, children: [
|
|
160
|
+
question.name,
|
|
161
|
+
question.required && /* @__PURE__ */ jsx("span", { className: "text-red-500 ml-1", "aria-hidden": "true", children: "*" })
|
|
162
|
+
] })
|
|
163
|
+
] }),
|
|
164
|
+
renderHelpText(),
|
|
165
|
+
renderError()
|
|
166
|
+
] });
|
|
167
|
+
default:
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function BookingForm({
|
|
172
|
+
questions,
|
|
173
|
+
onSubmit,
|
|
174
|
+
submitLabel = "Submit",
|
|
175
|
+
className = "",
|
|
176
|
+
labelClassName,
|
|
177
|
+
classNames: classNamesProp
|
|
178
|
+
}) {
|
|
179
|
+
const classNames = {
|
|
180
|
+
...classNamesProp,
|
|
181
|
+
label: classNamesProp?.label ?? labelClassName
|
|
182
|
+
};
|
|
183
|
+
const [values, setValues] = useState({});
|
|
184
|
+
const [errors, setErrors] = useState({});
|
|
185
|
+
const [touched, setTouched] = useState({});
|
|
186
|
+
const isQuestionVisible = useCallback(
|
|
187
|
+
(question) => {
|
|
188
|
+
const { settings } = question;
|
|
189
|
+
if (!settings?.conditional_answers) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
const conditionEntries = Object.entries(settings.conditional_answers);
|
|
193
|
+
if (conditionEntries.length === 0) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
return conditionEntries.some(([questionIdStr, expectedAnswer]) => {
|
|
197
|
+
const questionId = Number(questionIdStr);
|
|
198
|
+
const currentValue = values[String(questionId)];
|
|
199
|
+
if (currentValue === void 0 || currentValue === "" || currentValue === null) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
const normalizedCurrent = typeof currentValue === "object" && currentValue !== null && "id" in currentValue ? currentValue : currentValue;
|
|
203
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
204
|
+
const addCandidate = (v) => {
|
|
205
|
+
if (v === void 0 || v === null) return;
|
|
206
|
+
const s = String(v).trim();
|
|
207
|
+
if (s) candidates.add(s);
|
|
208
|
+
};
|
|
209
|
+
const sourceQuestion = questions.find((q) => q.id === questionId);
|
|
210
|
+
if (typeof normalizedCurrent === "object" && normalizedCurrent !== null) {
|
|
211
|
+
addCandidate(normalizedCurrent.id);
|
|
212
|
+
addCandidate(normalizedCurrent.name);
|
|
213
|
+
} else {
|
|
214
|
+
addCandidate(normalizedCurrent);
|
|
215
|
+
}
|
|
216
|
+
if (sourceQuestion?.detail_type === "select" && sourceQuestion.options?.length) {
|
|
217
|
+
const currentStr = [...candidates][0] ?? "";
|
|
218
|
+
const optionById = sourceQuestion.options.find((o) => String(o.id) === currentStr);
|
|
219
|
+
const optionByName = sourceQuestion.options.find((o) => String(o.name).trim() === currentStr);
|
|
220
|
+
const opt = optionById ?? optionByName;
|
|
221
|
+
if (opt) {
|
|
222
|
+
addCandidate(opt.id);
|
|
223
|
+
addCandidate(opt.name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const matchesScalar = (expected) => {
|
|
227
|
+
const expectedStr = String(expected).trim();
|
|
228
|
+
return candidates.has(expectedStr);
|
|
229
|
+
};
|
|
230
|
+
if (Array.isArray(expectedAnswer)) {
|
|
231
|
+
return expectedAnswer.some((v) => matchesScalar(v));
|
|
232
|
+
}
|
|
233
|
+
if (expectedAnswer && typeof expectedAnswer === "object") {
|
|
234
|
+
const expectedMap = expectedAnswer;
|
|
235
|
+
for (const c of candidates) {
|
|
236
|
+
if (Object.prototype.hasOwnProperty.call(expectedMap, c)) {
|
|
237
|
+
const flag = expectedMap[c];
|
|
238
|
+
return flag === void 0 ? true : !!flag;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return matchesScalar(expectedAnswer);
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
[values, questions]
|
|
247
|
+
);
|
|
248
|
+
const visibleQuestions = questions.filter(isQuestionVisible);
|
|
249
|
+
const validateField = useCallback((question, value) => {
|
|
250
|
+
if (question.detail_type === "heading") return null;
|
|
251
|
+
if (question.required) {
|
|
252
|
+
if (value === void 0 || value === "" || value === null) {
|
|
253
|
+
return `${question.name} is required`;
|
|
254
|
+
}
|
|
255
|
+
if (question.detail_type === "check" && !value) {
|
|
256
|
+
return `${question.name} must be checked`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (question.detail_type === "number" && value !== "" && value !== void 0) {
|
|
260
|
+
const numValue = Number(value);
|
|
261
|
+
if (question.settings?.min !== void 0 && numValue < question.settings.min) {
|
|
262
|
+
return `Minimum value is ${question.settings.min}`;
|
|
263
|
+
}
|
|
264
|
+
if (question.settings?.max !== void 0 && numValue > question.settings.max) {
|
|
265
|
+
return `Maximum value is ${question.settings.max}`;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}, []);
|
|
270
|
+
const validateAll = useCallback(() => {
|
|
271
|
+
const newErrors = {};
|
|
272
|
+
let isValid = true;
|
|
273
|
+
visibleQuestions.forEach((question) => {
|
|
274
|
+
const error = validateField(question, values[question.id]);
|
|
275
|
+
if (error) {
|
|
276
|
+
newErrors[question.id] = error;
|
|
277
|
+
isValid = false;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
setErrors(newErrors);
|
|
281
|
+
return isValid;
|
|
282
|
+
}, [visibleQuestions, values, validateField]);
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
const visibleIds = new Set(visibleQuestions.map((q) => q.id));
|
|
285
|
+
setErrors((prev) => {
|
|
286
|
+
let changed = false;
|
|
287
|
+
const next = { ...prev };
|
|
288
|
+
Object.keys(next).forEach((idStr) => {
|
|
289
|
+
const id = Number(idStr);
|
|
290
|
+
if (!visibleIds.has(id)) {
|
|
291
|
+
delete next[id];
|
|
292
|
+
changed = true;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
return changed ? next : prev;
|
|
296
|
+
});
|
|
297
|
+
}, [visibleQuestions]);
|
|
298
|
+
const handleChange = (questionId, value) => {
|
|
299
|
+
setValues((prev) => ({ ...prev, [questionId]: value }));
|
|
300
|
+
setTouched((prev) => ({ ...prev, [questionId]: true }));
|
|
301
|
+
if (touched[questionId]) {
|
|
302
|
+
const question = questions.find((q) => q.id === questionId);
|
|
303
|
+
if (question) {
|
|
304
|
+
const error = validateField(question, value);
|
|
305
|
+
setErrors((prev) => {
|
|
306
|
+
if (error) {
|
|
307
|
+
return { ...prev, [questionId]: error };
|
|
308
|
+
}
|
|
309
|
+
const { [questionId]: _, ...rest } = prev;
|
|
310
|
+
return rest;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const handleSubmit = (e) => {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
const allTouched = {};
|
|
318
|
+
visibleQuestions.forEach((q) => {
|
|
319
|
+
allTouched[q.id] = true;
|
|
320
|
+
});
|
|
321
|
+
setTouched(allTouched);
|
|
322
|
+
if (validateAll()) {
|
|
323
|
+
const visibleValues = {};
|
|
324
|
+
visibleQuestions.forEach((q) => {
|
|
325
|
+
if (q.detail_type !== "heading" && values[q.id] !== void 0) {
|
|
326
|
+
visibleValues[q.id] = values[q.id];
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
onSubmit(visibleValues);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const defaultButton = "w-full mt-4 px-4 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors";
|
|
333
|
+
return /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className, noValidate: true, children: [
|
|
334
|
+
visibleQuestions.map((question) => /* @__PURE__ */ jsx(
|
|
335
|
+
FormField,
|
|
336
|
+
{
|
|
337
|
+
question,
|
|
338
|
+
value: values[question.id],
|
|
339
|
+
error: touched[question.id] ? errors[question.id] : void 0,
|
|
340
|
+
onChange: (value) => handleChange(question.id, value),
|
|
341
|
+
classNames
|
|
342
|
+
},
|
|
343
|
+
question.id
|
|
344
|
+
)),
|
|
345
|
+
/* @__PURE__ */ jsx(
|
|
346
|
+
"button",
|
|
347
|
+
{
|
|
348
|
+
type: "submit",
|
|
349
|
+
className: classNames?.button ?? defaultButton,
|
|
350
|
+
children: submitLabel
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
] });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/utils/validators.ts
|
|
357
|
+
function required(value) {
|
|
358
|
+
if (!value || value.trim() === "") {
|
|
359
|
+
return "This field is required";
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
function email(value) {
|
|
364
|
+
if (!value) return null;
|
|
365
|
+
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
366
|
+
if (!emailRegex.test(value)) {
|
|
367
|
+
return "Please enter a valid email address";
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
function phone(value) {
|
|
372
|
+
if (!value) return null;
|
|
373
|
+
const cleaned = value.replace(/[\s\-\(\)]/g, "");
|
|
374
|
+
const phoneRegex = /^\+?[0-9]{7,15}$/;
|
|
375
|
+
if (!phoneRegex.test(cleaned)) {
|
|
376
|
+
return "Please enter a valid phone number";
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
function ukPostcode(value) {
|
|
381
|
+
if (!value) return null;
|
|
382
|
+
const postcodeRegex = /^([A-Z]{1,2}[0-9][A-Z0-9]? ?[0-9][A-Z]{2})$/i;
|
|
383
|
+
if (!postcodeRegex.test(value.trim())) {
|
|
384
|
+
return "Please enter a valid UK postcode";
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
function minLen(min) {
|
|
389
|
+
return (value) => {
|
|
390
|
+
if (!value) return null;
|
|
391
|
+
if (value.trim().length < min) {
|
|
392
|
+
return `Must be at least ${min} character${min === 1 ? "" : "s"}`;
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function compose(...validators) {
|
|
398
|
+
return (value) => {
|
|
399
|
+
for (const validator of validators) {
|
|
400
|
+
const error = validator(value);
|
|
401
|
+
if (error) return error;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
var DEFAULT_FIELDS = [
|
|
407
|
+
{
|
|
408
|
+
name: "firstName",
|
|
409
|
+
label: "First name",
|
|
410
|
+
type: "text",
|
|
411
|
+
required: true,
|
|
412
|
+
validate: required
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
name: "lastName",
|
|
416
|
+
label: "Last name",
|
|
417
|
+
type: "text",
|
|
418
|
+
required: true,
|
|
419
|
+
validate: required
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "email",
|
|
423
|
+
label: "Email address",
|
|
424
|
+
type: "email",
|
|
425
|
+
required: true,
|
|
426
|
+
validate: compose(required, email)
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: "phone",
|
|
430
|
+
label: "Contact number",
|
|
431
|
+
type: "tel",
|
|
432
|
+
required: false,
|
|
433
|
+
validate: phone
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "address1",
|
|
437
|
+
label: "Address 1",
|
|
438
|
+
type: "text",
|
|
439
|
+
required: true,
|
|
440
|
+
validate: required
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "address2",
|
|
444
|
+
label: "Address 2",
|
|
445
|
+
type: "text",
|
|
446
|
+
required: false
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: "city",
|
|
450
|
+
label: "Town/City",
|
|
451
|
+
type: "text",
|
|
452
|
+
required: true,
|
|
453
|
+
validate: required
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: "postcode",
|
|
457
|
+
label: "Postcode",
|
|
458
|
+
type: "text",
|
|
459
|
+
required: true,
|
|
460
|
+
validate: compose(required, ukPostcode)
|
|
461
|
+
}
|
|
462
|
+
];
|
|
463
|
+
var RegistrationForm = forwardRef(
|
|
464
|
+
({
|
|
465
|
+
fields = DEFAULT_FIELDS,
|
|
466
|
+
onSubmit,
|
|
467
|
+
onChange,
|
|
468
|
+
validateOnBlur = true,
|
|
469
|
+
submitLabel = "Submit",
|
|
470
|
+
className = "",
|
|
471
|
+
classNames = {}
|
|
472
|
+
}, ref) => {
|
|
473
|
+
const formId = useId();
|
|
474
|
+
const [values, setValues] = useState({});
|
|
475
|
+
const [errors, setErrors] = useState({});
|
|
476
|
+
const [touched, setTouched] = useState({});
|
|
477
|
+
const validateField = useCallback(
|
|
478
|
+
(field, value) => {
|
|
479
|
+
if (field.required && (!value || value.trim() === "")) {
|
|
480
|
+
return "This field is required";
|
|
481
|
+
}
|
|
482
|
+
if (field.validate && value) {
|
|
483
|
+
return field.validate(value);
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
},
|
|
487
|
+
[]
|
|
488
|
+
);
|
|
489
|
+
const validateAll = useCallback(() => {
|
|
490
|
+
const newErrors = {};
|
|
491
|
+
let isValid = true;
|
|
492
|
+
for (const field of fields) {
|
|
493
|
+
const value = values[field.name] || "";
|
|
494
|
+
const error = validateField(field, value);
|
|
495
|
+
if (error) {
|
|
496
|
+
newErrors[field.name] = error;
|
|
497
|
+
isValid = false;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
setErrors(newErrors);
|
|
501
|
+
return isValid;
|
|
502
|
+
}, [fields, values, validateField]);
|
|
503
|
+
const checkIsValid = useCallback(
|
|
504
|
+
(currentValues) => {
|
|
505
|
+
for (const field of fields) {
|
|
506
|
+
const value = currentValues[field.name] || "";
|
|
507
|
+
const error = validateField(field, value);
|
|
508
|
+
if (error) return false;
|
|
509
|
+
}
|
|
510
|
+
return true;
|
|
511
|
+
},
|
|
512
|
+
[fields, validateField]
|
|
513
|
+
);
|
|
514
|
+
const handleChange = useCallback(
|
|
515
|
+
(fieldName, value) => {
|
|
516
|
+
const newValues = { ...values, [fieldName]: value };
|
|
517
|
+
setValues(newValues);
|
|
518
|
+
if (touched[fieldName]) {
|
|
519
|
+
const field = fields.find((f) => f.name === fieldName);
|
|
520
|
+
if (field) {
|
|
521
|
+
const error = validateField(field, value);
|
|
522
|
+
if (!error) {
|
|
523
|
+
setErrors((prev) => {
|
|
524
|
+
const next = { ...prev };
|
|
525
|
+
delete next[fieldName];
|
|
526
|
+
return next;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (onChange) {
|
|
532
|
+
const isValid = checkIsValid(newValues);
|
|
533
|
+
onChange(newValues, isValid);
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
[values, touched, fields, validateField, onChange, checkIsValid]
|
|
537
|
+
);
|
|
538
|
+
const handleBlur = useCallback(
|
|
539
|
+
(fieldName) => {
|
|
540
|
+
setTouched((prev) => ({ ...prev, [fieldName]: true }));
|
|
541
|
+
if (validateOnBlur) {
|
|
542
|
+
const field = fields.find((f) => f.name === fieldName);
|
|
543
|
+
if (field) {
|
|
544
|
+
const value = values[fieldName] || "";
|
|
545
|
+
const error = validateField(field, value);
|
|
546
|
+
if (error) {
|
|
547
|
+
setErrors((prev) => ({ ...prev, [fieldName]: error }));
|
|
548
|
+
} else {
|
|
549
|
+
setErrors((prev) => {
|
|
550
|
+
const next = { ...prev };
|
|
551
|
+
delete next[fieldName];
|
|
552
|
+
return next;
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
[validateOnBlur, fields, values, validateField]
|
|
559
|
+
);
|
|
560
|
+
const handleSubmit = useCallback(
|
|
561
|
+
(e) => {
|
|
562
|
+
e.preventDefault();
|
|
563
|
+
const allTouched = {};
|
|
564
|
+
for (const field of fields) {
|
|
565
|
+
allTouched[field.name] = true;
|
|
566
|
+
}
|
|
567
|
+
setTouched(allTouched);
|
|
568
|
+
if (validateAll()) {
|
|
569
|
+
onSubmit(values);
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
[fields, validateAll, onSubmit, values]
|
|
573
|
+
);
|
|
574
|
+
useImperativeHandle(
|
|
575
|
+
ref,
|
|
576
|
+
() => ({
|
|
577
|
+
reset: () => {
|
|
578
|
+
setValues({});
|
|
579
|
+
setErrors({});
|
|
580
|
+
setTouched({});
|
|
581
|
+
},
|
|
582
|
+
setValues: (newValues) => {
|
|
583
|
+
setValues((prev) => {
|
|
584
|
+
const merged = { ...prev };
|
|
585
|
+
for (const [key, value] of Object.entries(newValues)) {
|
|
586
|
+
if (value !== void 0) {
|
|
587
|
+
merged[key] = value;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return merged;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}),
|
|
594
|
+
[]
|
|
595
|
+
);
|
|
596
|
+
const styles = {
|
|
597
|
+
fieldWrapper: classNames.fieldWrapper || "mb-4",
|
|
598
|
+
label: classNames.label || "block text-sm font-medium mb-1",
|
|
599
|
+
input: classNames.input || "w-full px-3 py-2 border rounded-md",
|
|
600
|
+
inputError: classNames.inputError || "border-red-500",
|
|
601
|
+
errorText: classNames.errorText || "mt-1 text-xs text-red-600",
|
|
602
|
+
button: classNames.button || "w-full mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
603
|
+
};
|
|
604
|
+
return /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className, noValidate: true, children: [
|
|
605
|
+
fields.map((field) => {
|
|
606
|
+
const fieldId = `${formId}-${field.name}`;
|
|
607
|
+
const errorId = `${fieldId}-error`;
|
|
608
|
+
const value = values[field.name] || "";
|
|
609
|
+
const error = errors[field.name];
|
|
610
|
+
const isTouched = touched[field.name];
|
|
611
|
+
const showError = isTouched && error;
|
|
612
|
+
return /* @__PURE__ */ jsxs("div", { className: styles.fieldWrapper, children: [
|
|
613
|
+
/* @__PURE__ */ jsxs("label", { htmlFor: fieldId, className: styles.label, children: [
|
|
614
|
+
field.label,
|
|
615
|
+
field.required && /* @__PURE__ */ jsx("span", { className: "text-red-500 ml-1", "aria-hidden": "true", children: "*" })
|
|
616
|
+
] }),
|
|
617
|
+
/* @__PURE__ */ jsx(
|
|
618
|
+
"input",
|
|
619
|
+
{
|
|
620
|
+
id: fieldId,
|
|
621
|
+
name: field.name,
|
|
622
|
+
type: field.type,
|
|
623
|
+
value,
|
|
624
|
+
onChange: (e) => handleChange(field.name, e.target.value),
|
|
625
|
+
onBlur: () => handleBlur(field.name),
|
|
626
|
+
placeholder: field.placeholder,
|
|
627
|
+
className: `${styles.input} ${showError ? styles.inputError : ""}`,
|
|
628
|
+
"aria-invalid": showError ? "true" : "false",
|
|
629
|
+
"aria-describedby": showError ? errorId : void 0,
|
|
630
|
+
"aria-required": field.required ? "true" : "false"
|
|
631
|
+
}
|
|
632
|
+
),
|
|
633
|
+
showError && /* @__PURE__ */ jsx("p", { id: errorId, className: styles.errorText, role: "alert", children: error })
|
|
634
|
+
] }, field.name);
|
|
635
|
+
}),
|
|
636
|
+
/* @__PURE__ */ jsx("button", { type: "submit", className: styles.button, children: submitLabel })
|
|
637
|
+
] });
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
RegistrationForm.displayName = "RegistrationForm";
|
|
641
|
+
|
|
642
|
+
export { BookingForm, RegistrationForm, compose, email, minLen, phone, required, ukPostcode };
|
|
643
|
+
//# sourceMappingURL=index.mjs.map
|
|
644
|
+
//# sourceMappingURL=index.mjs.map
|