@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.
- package/README.md +298 -0
- package/dist/lub-forms.css +1 -0
- package/dist/lub-forms.es.js +5848 -0
- package/dist/lub-forms.es.js.map +1 -0
- package/dist/lub-forms.standalone.js +10 -0
- package/dist/lub-forms.standalone.js.map +1 -0
- package/dist/lub-forms.umd.js +227 -0
- package/dist/lub-forms.umd.js.map +1 -0
- package/package.json +68 -0
- package/src/api/client.ts +115 -0
- package/src/api/index.ts +2 -0
- package/src/api/types.ts +202 -0
- package/src/core/FormProvider.tsx +228 -0
- package/src/core/FormRenderer.tsx +134 -0
- package/src/core/LubForm.tsx +476 -0
- package/src/core/StepManager.tsx +199 -0
- package/src/core/index.ts +4 -0
- package/src/embed.ts +188 -0
- package/src/fields/CheckboxField.tsx +62 -0
- package/src/fields/CheckboxGroupField.tsx +57 -0
- package/src/fields/CountryField.tsx +43 -0
- package/src/fields/DateField.tsx +33 -0
- package/src/fields/DateTimeField.tsx +33 -0
- package/src/fields/DividerField.tsx +16 -0
- package/src/fields/FieldWrapper.tsx +60 -0
- package/src/fields/FileField.tsx +45 -0
- package/src/fields/HiddenField.tsx +18 -0
- package/src/fields/HtmlField.tsx +17 -0
- package/src/fields/NumberField.tsx +39 -0
- package/src/fields/RadioField.tsx +57 -0
- package/src/fields/RecaptchaField.tsx +137 -0
- package/src/fields/SelectField.tsx +49 -0
- package/src/fields/StateField.tsx +84 -0
- package/src/fields/TextField.tsx +51 -0
- package/src/fields/TextareaField.tsx +37 -0
- package/src/fields/TimeField.tsx +33 -0
- package/src/fields/index.ts +84 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useConditionalLogic.ts +59 -0
- package/src/hooks/useFormApi.ts +118 -0
- package/src/hooks/useFormDesign.ts +48 -0
- package/src/hooks/useMultiStep.ts +98 -0
- package/src/index.ts +101 -0
- package/src/main.tsx +40 -0
- package/src/styles/index.css +707 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/countries.ts +163 -0
- package/src/utils/css-variables.ts +63 -0
- package/src/utils/index.ts +3 -0
- package/src/validation/conditional.ts +170 -0
- package/src/validation/index.ts +2 -0
- 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
|
+
}
|