@lub-crm/forms 1.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 (52) hide show
  1. package/README.md +298 -0
  2. package/dist/lub-forms.css +1 -0
  3. package/dist/lub-forms.es.js +5848 -0
  4. package/dist/lub-forms.es.js.map +1 -0
  5. package/dist/lub-forms.standalone.js +10 -0
  6. package/dist/lub-forms.standalone.js.map +1 -0
  7. package/dist/lub-forms.umd.js +227 -0
  8. package/dist/lub-forms.umd.js.map +1 -0
  9. package/package.json +68 -0
  10. package/src/api/client.ts +115 -0
  11. package/src/api/index.ts +2 -0
  12. package/src/api/types.ts +202 -0
  13. package/src/core/FormProvider.tsx +228 -0
  14. package/src/core/FormRenderer.tsx +134 -0
  15. package/src/core/LubForm.tsx +476 -0
  16. package/src/core/StepManager.tsx +199 -0
  17. package/src/core/index.ts +4 -0
  18. package/src/embed.ts +188 -0
  19. package/src/fields/CheckboxField.tsx +62 -0
  20. package/src/fields/CheckboxGroupField.tsx +57 -0
  21. package/src/fields/CountryField.tsx +43 -0
  22. package/src/fields/DateField.tsx +33 -0
  23. package/src/fields/DateTimeField.tsx +33 -0
  24. package/src/fields/DividerField.tsx +16 -0
  25. package/src/fields/FieldWrapper.tsx +60 -0
  26. package/src/fields/FileField.tsx +45 -0
  27. package/src/fields/HiddenField.tsx +18 -0
  28. package/src/fields/HtmlField.tsx +17 -0
  29. package/src/fields/NumberField.tsx +39 -0
  30. package/src/fields/RadioField.tsx +57 -0
  31. package/src/fields/RecaptchaField.tsx +137 -0
  32. package/src/fields/SelectField.tsx +49 -0
  33. package/src/fields/StateField.tsx +84 -0
  34. package/src/fields/TextField.tsx +51 -0
  35. package/src/fields/TextareaField.tsx +37 -0
  36. package/src/fields/TimeField.tsx +33 -0
  37. package/src/fields/index.ts +84 -0
  38. package/src/hooks/index.ts +4 -0
  39. package/src/hooks/useConditionalLogic.ts +59 -0
  40. package/src/hooks/useFormApi.ts +118 -0
  41. package/src/hooks/useFormDesign.ts +48 -0
  42. package/src/hooks/useMultiStep.ts +98 -0
  43. package/src/index.ts +101 -0
  44. package/src/main.tsx +40 -0
  45. package/src/styles/index.css +707 -0
  46. package/src/utils/cn.ts +6 -0
  47. package/src/utils/countries.ts +163 -0
  48. package/src/utils/css-variables.ts +63 -0
  49. package/src/utils/index.ts +3 -0
  50. package/src/validation/conditional.ts +170 -0
  51. package/src/validation/index.ts +2 -0
  52. package/src/validation/schema-builder.ts +327 -0
@@ -0,0 +1,134 @@
1
+ import { useFormContext, type FieldValues } from "react-hook-form";
2
+ import type { FormField, FormLayout } from "@/api/types";
3
+ import { cn } from "@/utils/cn";
4
+ import { getFieldComponent } from "@/fields";
5
+ import { useLubFormContext } from "./FormProvider";
6
+
7
+ interface FormRendererProps {
8
+ fields: FormField[];
9
+ layout?: FormLayout;
10
+ }
11
+
12
+ export function FormRenderer({
13
+ fields,
14
+ layout = "vertical",
15
+ }: FormRendererProps) {
16
+ const { watch } = useFormContext();
17
+ const { getVisibleFields } = useLubFormContext();
18
+
19
+ // Watch all form values to re-evaluate conditional logic
20
+ const formValues = watch();
21
+ const visibleFields = getVisibleFields(formValues);
22
+
23
+ // Group fields for two-column layout
24
+ const fieldGroups =
25
+ layout === "two_column"
26
+ ? groupFieldsForTwoColumn(visibleFields)
27
+ : visibleFields.map((f) => [f]);
28
+
29
+ return (
30
+ <div className={cn("lub-form__fields", `lub-form__fields--${layout}`)}>
31
+ {fieldGroups.map((group, groupIndex) => (
32
+ <div
33
+ key={group.map((f) => f.id).join("-") || groupIndex}
34
+ className={cn(
35
+ "lub-form__field-group",
36
+ group.length > 1 && "lub-form__field-group--row",
37
+ )}
38
+ >
39
+ {group.map((field) => (
40
+ <FieldRenderer key={field.id} field={field} />
41
+ ))}
42
+ </div>
43
+ ))}
44
+ </div>
45
+ );
46
+ }
47
+
48
+ interface FieldRendererProps {
49
+ field: FormField;
50
+ }
51
+
52
+ function FieldRenderer({ field }: FieldRendererProps) {
53
+ const FieldComponent = getFieldComponent(field.field_type);
54
+
55
+ if (!FieldComponent) {
56
+ console.warn(`Unknown field type: ${field.field_type}`);
57
+ return null;
58
+ }
59
+
60
+ const widthClass = getWidthClass(field.width);
61
+
62
+ return (
63
+ <div className={cn("lub-form__field", widthClass, field.css_class)}>
64
+ <FieldComponent field={field} />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ function getWidthClass(width: FormField["width"]): string {
70
+ switch (width) {
71
+ case "half":
72
+ return "lub-form__field--half";
73
+ case "third":
74
+ return "lub-form__field--third";
75
+ case "quarter":
76
+ return "lub-form__field--quarter";
77
+ case "full":
78
+ default:
79
+ return "lub-form__field--full";
80
+ }
81
+ }
82
+
83
+ // Group fields for two-column layout respecting width settings
84
+ function groupFieldsForTwoColumn(fields: FormField[]): FormField[][] {
85
+ const groups: FormField[][] = [];
86
+ let currentRow: FormField[] = [];
87
+ let currentRowWidth = 0;
88
+
89
+ for (const field of fields) {
90
+ const fieldWidth = getFieldWidthValue(field.width);
91
+
92
+ // Full-width fields always go on their own row
93
+ if (fieldWidth >= 1) {
94
+ if (currentRow.length > 0) {
95
+ groups.push(currentRow);
96
+ currentRow = [];
97
+ currentRowWidth = 0;
98
+ }
99
+ groups.push([field]);
100
+ continue;
101
+ }
102
+
103
+ // Check if field fits in current row
104
+ if (currentRowWidth + fieldWidth > 1) {
105
+ groups.push(currentRow);
106
+ currentRow = [field];
107
+ currentRowWidth = fieldWidth;
108
+ } else {
109
+ currentRow.push(field);
110
+ currentRowWidth += fieldWidth;
111
+ }
112
+ }
113
+
114
+ // Push remaining fields
115
+ if (currentRow.length > 0) {
116
+ groups.push(currentRow);
117
+ }
118
+
119
+ return groups;
120
+ }
121
+
122
+ function getFieldWidthValue(width: FormField["width"]): number {
123
+ switch (width) {
124
+ case "quarter":
125
+ return 0.25;
126
+ case "third":
127
+ return 0.333;
128
+ case "half":
129
+ return 0.5;
130
+ case "full":
131
+ default:
132
+ return 1;
133
+ }
134
+ }
@@ -0,0 +1,476 @@
1
+ import { useState, useEffect, useCallback, type CSSProperties } from "react";
2
+ import { useFormContext } from "react-hook-form";
3
+ import type {
4
+ PublicFormResponse,
5
+ FormDesign,
6
+ SubmitFormResponse,
7
+ ApiError,
8
+ } from "@/api/types";
9
+ import { LubFormsClient } from "@/api/client";
10
+ import { LubFormProvider } from "./FormProvider";
11
+ import { FormRenderer } from "./FormRenderer";
12
+ import { StepManager, StepNavigation } from "./StepManager";
13
+ import { buildFormSchema } from "@/validation/schema-builder";
14
+ import { applyCssVariables } from "@/utils/css-variables";
15
+ import { cn } from "@/utils/cn";
16
+
17
+ // Public component props
18
+ export interface LubFormProps {
19
+ formId: string;
20
+ baseUrl?: string;
21
+
22
+ // Callbacks
23
+ onSuccess?: (data: SubmitFormResponse) => void;
24
+ onError?: (error: ApiError) => void;
25
+ onValidationError?: (errors: Record<string, string>) => void;
26
+ onStepChange?: (step: number, total: number) => void;
27
+
28
+ // Styling
29
+ className?: string;
30
+ style?: CSSProperties;
31
+ designOverrides?: Partial<FormDesign>;
32
+ }
33
+
34
+ type FormState =
35
+ | { status: "loading" }
36
+ | { status: "error"; error: string }
37
+ | { status: "ready"; form: PublicFormResponse }
38
+ | {
39
+ status: "success";
40
+ form: PublicFormResponse;
41
+ response: SubmitFormResponse;
42
+ };
43
+
44
+ export function LubForm({
45
+ formId,
46
+ baseUrl = "",
47
+ onSuccess,
48
+ onError,
49
+ onValidationError,
50
+ onStepChange,
51
+ className,
52
+ style,
53
+ designOverrides,
54
+ }: LubFormProps) {
55
+ const [state, setState] = useState<FormState>({ status: "loading" });
56
+ const [currentStep, setCurrentStep] = useState(0);
57
+ const [isSubmitting, setIsSubmitting] = useState(false);
58
+
59
+ // Fetch form on mount
60
+ useEffect(() => {
61
+ const client = new LubFormsClient(baseUrl);
62
+
63
+ client
64
+ .getForm(formId)
65
+ .then((form) => {
66
+ setState({ status: "ready", form });
67
+ })
68
+ .catch((error: ApiError) => {
69
+ setState({ status: "error", error: error.error });
70
+ onError?.(error);
71
+ });
72
+ }, [formId, baseUrl, onError]);
73
+
74
+ // Handle step change
75
+ const handleStepChange = useCallback(
76
+ (step: number) => {
77
+ setCurrentStep(step);
78
+ if (state.status === "ready") {
79
+ const totalSteps = state.form.is_multi_step
80
+ ? (state.form.steps?.length ?? 1)
81
+ : 1;
82
+ onStepChange?.(step, totalSteps);
83
+ }
84
+ },
85
+ [state, onStepChange],
86
+ );
87
+
88
+ // Render based on state
89
+ if (state.status === "loading") {
90
+ return <FormSkeleton className={className} style={style} />;
91
+ }
92
+
93
+ if (state.status === "error") {
94
+ return (
95
+ <div className={cn("lub-form lub-form--error", className)} style={style}>
96
+ <div className="lub-form__error-container">
97
+ <p className="lub-form__error-message">{state.error}</p>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ if (state.status === "success") {
104
+ return (
105
+ <SuccessMessage
106
+ message={state.form.settings.success_message}
107
+ className={className}
108
+ style={style}
109
+ design={mergeDesign(state.form.design, designOverrides)}
110
+ />
111
+ );
112
+ }
113
+
114
+ const { form } = state;
115
+ const totalSteps = form.is_multi_step ? (form.steps?.length ?? 1) : 1;
116
+ const mergedDesign = mergeDesign(form.design, designOverrides);
117
+ const cssVars = applyCssVariables(mergedDesign);
118
+
119
+ // Build validation schema
120
+ const schema = buildFormSchema(form.fields);
121
+
122
+ // Build default values
123
+ const defaultValues = buildDefaultValues(form);
124
+
125
+ return (
126
+ <div className={cn("lub-form", className)} style={{ ...cssVars, ...style }}>
127
+ <LubFormProvider
128
+ form={form}
129
+ schema={schema}
130
+ defaultValues={defaultValues}
131
+ currentStep={currentStep}
132
+ totalSteps={totalSteps}
133
+ isSubmitting={isSubmitting}
134
+ isSuccess={false}
135
+ error={null}
136
+ onStepChange={handleStepChange}
137
+ >
138
+ <FormContent
139
+ form={form}
140
+ currentStep={currentStep}
141
+ totalSteps={totalSteps}
142
+ isSubmitting={isSubmitting}
143
+ baseUrl={baseUrl}
144
+ onSubmitStart={() => setIsSubmitting(true)}
145
+ onSubmitEnd={() => setIsSubmitting(false)}
146
+ onSuccess={(response) => {
147
+ setState({ status: "success", form, response });
148
+ onSuccess?.(response);
149
+ }}
150
+ onError={onError}
151
+ onValidationError={onValidationError}
152
+ onStepChange={handleStepChange}
153
+ />
154
+ </LubFormProvider>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ // Internal form content component (needs form context)
160
+ interface FormContentProps {
161
+ form: PublicFormResponse;
162
+ currentStep: number;
163
+ totalSteps: number;
164
+ isSubmitting: boolean;
165
+ baseUrl: string;
166
+ onSubmitStart: () => void;
167
+ onSubmitEnd: () => void;
168
+ onSuccess: (response: SubmitFormResponse) => void;
169
+ onError?: (error: ApiError) => void;
170
+ onValidationError?: (errors: Record<string, string>) => void;
171
+ onStepChange: (step: number) => void;
172
+ }
173
+
174
+ function FormContent({
175
+ form,
176
+ currentStep,
177
+ totalSteps,
178
+ isSubmitting,
179
+ baseUrl,
180
+ onSubmitStart,
181
+ onSubmitEnd,
182
+ onSuccess,
183
+ onError,
184
+ onValidationError,
185
+ onStepChange,
186
+ }: FormContentProps) {
187
+ const { handleSubmit, trigger, formState } = useFormContext();
188
+ const isLastStep = currentStep >= totalSteps - 1;
189
+
190
+ // Get fields for current step
191
+ const currentFields =
192
+ form.is_multi_step && form.steps
193
+ ? form.fields.filter((f) =>
194
+ form.steps![currentStep]?.field_ids.includes(f.id),
195
+ )
196
+ : form.fields;
197
+
198
+ // Validate current step before proceeding
199
+ const validateCurrentStep = async (): Promise<boolean> => {
200
+ const fieldNames = currentFields
201
+ .filter((f) => f.is_active)
202
+ .map((f) => f.name);
203
+ const result = await trigger(fieldNames);
204
+ return result;
205
+ };
206
+
207
+ // Handle next step
208
+ const handleNextStep = async () => {
209
+ const isValid = await validateCurrentStep();
210
+ if (isValid) {
211
+ onStepChange(currentStep + 1);
212
+ } else if (onValidationError) {
213
+ const errors: Record<string, string> = {};
214
+ for (const [key, value] of Object.entries(formState.errors)) {
215
+ if (value?.message) {
216
+ errors[key] = value.message as string;
217
+ }
218
+ }
219
+ onValidationError(errors);
220
+ }
221
+ };
222
+
223
+ // Handle form submission
224
+ const onSubmit = async (data: Record<string, unknown>) => {
225
+ onSubmitStart();
226
+
227
+ try {
228
+ const client = new LubFormsClient(baseUrl);
229
+ const response = await client.submitForm(form.id, {
230
+ data,
231
+ referrer: typeof window !== "undefined" ? window.location.href : "",
232
+ utm_parameters: getUTMParameters(),
233
+ });
234
+ onSuccess(response);
235
+ } catch (error) {
236
+ onError?.(error as ApiError);
237
+ } finally {
238
+ onSubmitEnd();
239
+ }
240
+ };
241
+
242
+ return (
243
+ <form
244
+ className="lub-form__form"
245
+ onSubmit={handleSubmit(onSubmit)}
246
+ noValidate
247
+ >
248
+ {/* Header */}
249
+ {(form.name || form.description) && (
250
+ <div className="lub-form__header">
251
+ {form.name && <h2 className="lub-form__title">{form.name}</h2>}
252
+ {form.description && (
253
+ <p className="lub-form__description">{form.description}</p>
254
+ )}
255
+ </div>
256
+ )}
257
+
258
+ {/* Step indicator */}
259
+ {form.is_multi_step && form.steps && (
260
+ <StepManager
261
+ steps={form.steps}
262
+ currentStep={currentStep}
263
+ allowStepNavigation={false}
264
+ />
265
+ )}
266
+
267
+ {/* Fields */}
268
+ <FormRenderer fields={currentFields} layout={form.design.layout} />
269
+
270
+ {/* Consent checkbox */}
271
+ {form.settings.show_consent_checkbox && isLastStep && (
272
+ <ConsentCheckbox text={form.settings.consent_text} />
273
+ )}
274
+
275
+ {/* Navigation / Submit */}
276
+ {form.is_multi_step && totalSteps > 1 ? (
277
+ <StepNavigation
278
+ currentStep={currentStep}
279
+ totalSteps={totalSteps}
280
+ onNext={handleNextStep}
281
+ onPrev={() => onStepChange(currentStep - 1)}
282
+ isLastStep={isLastStep}
283
+ isSubmitting={isSubmitting}
284
+ submitButtonText={form.settings.submit_button_text || "Submit"}
285
+ />
286
+ ) : (
287
+ <div className="lub-form__actions">
288
+ <button
289
+ type="submit"
290
+ className="lub-form__button lub-form__button--primary"
291
+ disabled={isSubmitting}
292
+ >
293
+ {isSubmitting ? (
294
+ <span className="lub-form__button-loading">
295
+ <LoadingSpinner />
296
+ Submitting...
297
+ </span>
298
+ ) : (
299
+ form.settings.submit_button_text || "Submit"
300
+ )}
301
+ </button>
302
+ </div>
303
+ )}
304
+ </form>
305
+ );
306
+ }
307
+
308
+ // Consent checkbox component
309
+ function ConsentCheckbox({ text }: { text?: string }) {
310
+ const { register, formState } = useFormContext();
311
+ const error = formState.errors._consent;
312
+
313
+ return (
314
+ <div className="lub-form__consent">
315
+ <label className="lub-form__consent-label">
316
+ <input
317
+ type="checkbox"
318
+ className="lub-form__consent-checkbox"
319
+ {...register("_consent", {
320
+ required: "You must agree to continue",
321
+ })}
322
+ />
323
+ <span
324
+ className="lub-form__consent-text"
325
+ dangerouslySetInnerHTML={{
326
+ __html: text || "I agree to the terms and conditions",
327
+ }}
328
+ />
329
+ </label>
330
+ {error && (
331
+ <p className="lub-form__error" role="alert">
332
+ {error.message as string}
333
+ </p>
334
+ )}
335
+ </div>
336
+ );
337
+ }
338
+
339
+ // Loading skeleton
340
+ function FormSkeleton({
341
+ className,
342
+ style,
343
+ }: {
344
+ className?: string;
345
+ style?: CSSProperties;
346
+ }) {
347
+ return (
348
+ <div className={cn("lub-form lub-form--loading", className)} style={style}>
349
+ <div className="lub-form__skeleton">
350
+ <div className="lub-form__skeleton-title" />
351
+ <div className="lub-form__skeleton-field" />
352
+ <div className="lub-form__skeleton-field" />
353
+ <div className="lub-form__skeleton-field lub-form__skeleton-field--short" />
354
+ <div className="lub-form__skeleton-button" />
355
+ </div>
356
+ </div>
357
+ );
358
+ }
359
+
360
+ // Success message
361
+ function SuccessMessage({
362
+ message,
363
+ className,
364
+ style,
365
+ design,
366
+ }: {
367
+ message: string;
368
+ className?: string;
369
+ style?: CSSProperties;
370
+ design: FormDesign;
371
+ }) {
372
+ const cssVars = applyCssVariables(design);
373
+
374
+ return (
375
+ <div
376
+ className={cn("lub-form lub-form--success", className)}
377
+ style={{ ...cssVars, ...style }}
378
+ >
379
+ <div className="lub-form__success">
380
+ <SuccessIcon />
381
+ <p className="lub-form__success-message">{message}</p>
382
+ </div>
383
+ </div>
384
+ );
385
+ }
386
+
387
+ function SuccessIcon() {
388
+ return (
389
+ <svg
390
+ className="lub-form__success-icon"
391
+ viewBox="0 0 24 24"
392
+ fill="none"
393
+ stroke="currentColor"
394
+ strokeWidth="2"
395
+ strokeLinecap="round"
396
+ strokeLinejoin="round"
397
+ >
398
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
399
+ <polyline points="22,4 12,14.01 9,11.01" />
400
+ </svg>
401
+ );
402
+ }
403
+
404
+ function LoadingSpinner() {
405
+ return (
406
+ <svg className="lub-form__spinner" viewBox="0 0 24 24" fill="none">
407
+ <circle
408
+ className="lub-form__spinner-track"
409
+ cx="12"
410
+ cy="12"
411
+ r="10"
412
+ stroke="currentColor"
413
+ strokeWidth="3"
414
+ />
415
+ <path
416
+ className="lub-form__spinner-head"
417
+ d="M12 2a10 10 0 0 1 10 10"
418
+ stroke="currentColor"
419
+ strokeWidth="3"
420
+ strokeLinecap="round"
421
+ />
422
+ </svg>
423
+ );
424
+ }
425
+
426
+ // Helper functions
427
+ function mergeDesign(
428
+ base: FormDesign,
429
+ overrides?: Partial<FormDesign>,
430
+ ): FormDesign {
431
+ if (!overrides) return base;
432
+ return { ...base, ...overrides };
433
+ }
434
+
435
+ function buildDefaultValues(form: PublicFormResponse): Record<string, unknown> {
436
+ const defaults: Record<string, unknown> = {};
437
+
438
+ for (const field of form.fields) {
439
+ if (field.default_value) {
440
+ defaults[field.name] = field.default_value;
441
+ }
442
+
443
+ // Handle pre-selected options
444
+ if (field.options?.options) {
445
+ const selected = field.options.options.find((o) => o.selected);
446
+ if (selected) {
447
+ defaults[field.name] = selected.value;
448
+ }
449
+ }
450
+ }
451
+
452
+ return defaults;
453
+ }
454
+
455
+ function getUTMParameters() {
456
+ if (typeof window === "undefined") return undefined;
457
+
458
+ const params = new URLSearchParams(window.location.search);
459
+ const utm: Record<string, string> = {};
460
+
461
+ const utmParams = [
462
+ "utm_source",
463
+ "utm_medium",
464
+ "utm_campaign",
465
+ "utm_term",
466
+ "utm_content",
467
+ ];
468
+ for (const param of utmParams) {
469
+ const value = params.get(param);
470
+ if (value) {
471
+ utm[param.replace("utm_", "")] = value;
472
+ }
473
+ }
474
+
475
+ return Object.keys(utm).length > 0 ? utm : undefined;
476
+ }