@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
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@lub-crm/forms",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Embeddable form library for Lub CRM",
6
+ "main": "./dist/lub-forms.umd.js",
7
+ "module": "./dist/lub-forms.es.js",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "import": "./dist/lub-forms.es.js",
13
+ "require": "./dist/lub-forms.umd.js"
14
+ },
15
+ "./styles": "./dist/lub-forms.css"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "sideEffects": [
22
+ "*.css"
23
+ ],
24
+ "scripts": {
25
+ "dev": "vite",
26
+ "build": "bun run build:lib && bun run build:umd && bun run build:standalone && bun run build:clean",
27
+ "build:clean": "rm -f dist/forms.css dist/vite.svg",
28
+ "build:lib": "vite build --config vite.lib.config.ts",
29
+ "build:umd": "vite build --config vite.umd.config.ts",
30
+ "build:standalone": "vite build --config vite.standalone.config.ts",
31
+ "lint": "eslint .",
32
+ "preview": "vite preview",
33
+ "typecheck": "tsc --noEmit"
34
+ },
35
+ "dependencies": {
36
+ "@hookform/resolvers": "^3.9.0",
37
+ "react-hook-form": "^7.53.0",
38
+ "zod": "^3.23.0"
39
+ },
40
+ "peerDependencies": {
41
+ "react": "^18.0.0 || ^19.0.0",
42
+ "react-dom": "^18.0.0 || ^19.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@eslint/js": "^9.39.1",
46
+ "@types/node": "^24.10.1",
47
+ "@types/react": "^19.2.5",
48
+ "@types/react-dom": "^19.2.3",
49
+ "@vitejs/plugin-react": "^5.1.1",
50
+ "babel-plugin-react-compiler": "^1.0.0",
51
+ "eslint": "^9.39.1",
52
+ "eslint-plugin-react-hooks": "^7.0.1",
53
+ "eslint-plugin-react-refresh": "^0.4.24",
54
+ "globals": "^16.5.0",
55
+ "react": "^19.2.0",
56
+ "react-dom": "^19.2.0",
57
+ "typescript": "~5.9.3",
58
+ "typescript-eslint": "^8.46.4",
59
+ "vite": "^7.2.4"
60
+ },
61
+ "keywords": [
62
+ "forms",
63
+ "embed",
64
+ "crm",
65
+ "lub"
66
+ ],
67
+ "license": "MIT"
68
+ }
@@ -0,0 +1,115 @@
1
+ import type {
2
+ PublicFormResponse,
3
+ SubmitFormRequest,
4
+ SubmitFormResponse,
5
+ ApiError,
6
+ } from "./types";
7
+
8
+ export class LubFormsClient {
9
+ private baseUrl: string;
10
+
11
+ constructor(baseUrl: string) {
12
+ // Remove trailing slash
13
+ this.baseUrl = baseUrl.replace(/\/$/, "");
14
+ }
15
+
16
+ /**
17
+ * Fetch a form by ID for public display
18
+ */
19
+ async getForm(formId: string): Promise<PublicFormResponse> {
20
+ const url = `${this.baseUrl}/public/forms/${formId}`;
21
+
22
+ const response = await fetch(url, {
23
+ method: "GET",
24
+ headers: {
25
+ Accept: "application/json",
26
+ },
27
+ });
28
+
29
+ if (!response.ok) {
30
+ const error = await this.parseError(response);
31
+ throw error;
32
+ }
33
+
34
+ return response.json() as Promise<PublicFormResponse>;
35
+ }
36
+
37
+ /**
38
+ * Submit form data
39
+ */
40
+ async submitForm(
41
+ formId: string,
42
+ data: SubmitFormRequest,
43
+ ): Promise<SubmitFormResponse> {
44
+ const url = `${this.baseUrl}/public/forms/${formId}/submit`;
45
+
46
+ const response = await fetch(url, {
47
+ method: "POST",
48
+ headers: {
49
+ "Content-Type": "application/json",
50
+ Accept: "application/json",
51
+ },
52
+ body: JSON.stringify(data),
53
+ });
54
+
55
+ if (!response.ok) {
56
+ const error = await this.parseError(response);
57
+ throw error;
58
+ }
59
+
60
+ return response.json() as Promise<SubmitFormResponse>;
61
+ }
62
+
63
+ /**
64
+ * Confirm double opt-in
65
+ */
66
+ async confirmOptIn(
67
+ token: string,
68
+ ): Promise<{ success: boolean; message: string; submission_id: string }> {
69
+ const url = `${this.baseUrl}/public/forms/confirm/${token}`;
70
+
71
+ const response = await fetch(url, {
72
+ method: "GET",
73
+ headers: {
74
+ Accept: "application/json",
75
+ },
76
+ });
77
+
78
+ if (!response.ok) {
79
+ const error = await this.parseError(response);
80
+ throw error;
81
+ }
82
+
83
+ return response.json();
84
+ }
85
+
86
+ private async parseError(response: Response): Promise<ApiError> {
87
+ try {
88
+ const data = await response.json();
89
+ return {
90
+ error: data.error || data.message || "An error occurred",
91
+ code: data.code,
92
+ details: data.details,
93
+ };
94
+ } catch {
95
+ return {
96
+ error: `HTTP ${response.status}: ${response.statusText}`,
97
+ };
98
+ }
99
+ }
100
+ }
101
+
102
+ // Default client instance (can be set via LubForms.init)
103
+ let defaultClient: LubFormsClient | null = null;
104
+
105
+ export function setDefaultClient(client: LubFormsClient): void {
106
+ defaultClient = client;
107
+ }
108
+
109
+ export function getDefaultClient(): LubFormsClient | null {
110
+ return defaultClient;
111
+ }
112
+
113
+ export function createClient(baseUrl: string): LubFormsClient {
114
+ return new LubFormsClient(baseUrl);
115
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./types";
2
+ export * from "./client";
@@ -0,0 +1,202 @@
1
+ // Types matching backend marketing/forms DTOs
2
+
3
+ // Field types
4
+ export type FieldType =
5
+ | "text"
6
+ | "textarea"
7
+ | "email"
8
+ | "phone"
9
+ | "number"
10
+ | "url"
11
+ | "date"
12
+ | "time"
13
+ | "datetime"
14
+ | "select"
15
+ | "radio"
16
+ | "checkbox"
17
+ | "checkbox_group"
18
+ | "file"
19
+ | "hidden"
20
+ | "country"
21
+ | "state"
22
+ | "html"
23
+ | "divider"
24
+ | "recaptcha";
25
+
26
+ // Field width
27
+ export type FieldWidth = "full" | "half" | "third" | "quarter";
28
+
29
+ // Conditional operators
30
+ export type Operator =
31
+ | "equals"
32
+ | "not_equals"
33
+ | "contains"
34
+ | "not_contains"
35
+ | "greater_than"
36
+ | "less_than"
37
+ | "is_empty"
38
+ | "is_not_empty";
39
+
40
+ // Form layout
41
+ export type FormLayout = "vertical" | "horizontal" | "two_column";
42
+
43
+ // Validation rules
44
+ export interface ValidationRules {
45
+ min_length?: number;
46
+ max_length?: number;
47
+ min?: number;
48
+ max?: number;
49
+ pattern?: string;
50
+ allowed_types?: string[];
51
+ max_file_size?: number;
52
+ custom_error?: string;
53
+ }
54
+
55
+ // Select option
56
+ export interface SelectOption {
57
+ value: string;
58
+ label: string;
59
+ selected?: boolean;
60
+ }
61
+
62
+ // Field options (for select, radio, checkbox_group)
63
+ export interface FieldOptions {
64
+ options: SelectOption[];
65
+ allow_other?: boolean;
66
+ other_label?: string;
67
+ multiple?: boolean;
68
+ searchable?: boolean;
69
+ }
70
+
71
+ // Conditional logic
72
+ export interface Condition {
73
+ field_name: string;
74
+ operator: Operator;
75
+ value: unknown;
76
+ and?: Condition[];
77
+ or?: Condition[];
78
+ }
79
+
80
+ export interface ConditionalLogic {
81
+ show_if?: Condition;
82
+ hide_if?: Condition;
83
+ required_if?: Condition;
84
+ }
85
+
86
+ // CRM mapping
87
+ export interface CRMMapping {
88
+ object_type: string;
89
+ field_name: string;
90
+ is_custom_field?: boolean;
91
+ }
92
+
93
+ // Form field response
94
+ export interface FormField {
95
+ id: string;
96
+ field_type: FieldType;
97
+ name: string;
98
+ label: string;
99
+ placeholder?: string;
100
+ default_value?: string;
101
+ help_text?: string;
102
+ display_order: number;
103
+ required: boolean;
104
+ validation_rules?: ValidationRules;
105
+ options?: FieldOptions;
106
+ conditional_logic?: ConditionalLogic;
107
+ crm_mapping?: CRMMapping;
108
+ width: FieldWidth;
109
+ css_class?: string;
110
+ is_active: boolean;
111
+ }
112
+
113
+ // Button style
114
+ export interface ButtonStyle {
115
+ background_color?: string;
116
+ text_color?: string;
117
+ border_radius?: string;
118
+ size?: "small" | "medium" | "large";
119
+ full_width?: boolean;
120
+ }
121
+
122
+ // Form design
123
+ export interface FormDesign {
124
+ layout: FormLayout;
125
+ theme: string;
126
+ background_color?: string;
127
+ text_color?: string;
128
+ primary_color?: string;
129
+ border_color?: string;
130
+ font_family?: string;
131
+ font_size?: string;
132
+ padding?: string;
133
+ field_spacing?: string;
134
+ button_style?: ButtonStyle;
135
+ custom_css?: string;
136
+ show_logo?: boolean;
137
+ logo_url?: string;
138
+ }
139
+
140
+ // Form step (for multi-step forms)
141
+ export interface FormStep {
142
+ id: string;
143
+ name: string;
144
+ description?: string;
145
+ field_ids: string[];
146
+ display_order: number;
147
+ }
148
+
149
+ // Public form settings
150
+ export interface PublicFormSettings {
151
+ submit_button_text: string;
152
+ success_message: string;
153
+ enable_recaptcha: boolean;
154
+ recaptcha_site_key?: string;
155
+ show_consent_checkbox: boolean;
156
+ consent_text?: string;
157
+ }
158
+
159
+ // Public form response (from GET /public/forms/:id)
160
+ export interface PublicFormResponse {
161
+ id: string;
162
+ name: string;
163
+ description: string;
164
+ fields: FormField[];
165
+ design: FormDesign;
166
+ is_multi_step: boolean;
167
+ steps?: FormStep[];
168
+ settings: PublicFormSettings;
169
+ }
170
+
171
+ // UTM parameters
172
+ export interface UTMParameters {
173
+ source?: string;
174
+ medium?: string;
175
+ campaign?: string;
176
+ term?: string;
177
+ content?: string;
178
+ }
179
+
180
+ // Submit form request
181
+ export interface SubmitFormRequest {
182
+ data: Record<string, unknown>;
183
+ utm_parameters?: UTMParameters;
184
+ referrer?: string;
185
+ recaptcha_token?: string;
186
+ honeypot?: string;
187
+ }
188
+
189
+ // Submit form response
190
+ export interface SubmitFormResponse {
191
+ success: boolean;
192
+ submission_id: string;
193
+ message: string;
194
+ requires_confirmation?: boolean;
195
+ }
196
+
197
+ // API Error response
198
+ export interface ApiError {
199
+ error: string;
200
+ code?: string;
201
+ details?: Record<string, unknown>;
202
+ }
@@ -0,0 +1,228 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+ import {
3
+ useForm,
4
+ FormProvider as RHFFormProvider,
5
+ type UseFormReturn,
6
+ type FieldValues,
7
+ } from "react-hook-form";
8
+ import { zodResolver } from "@hookform/resolvers/zod";
9
+ import type { z } from "zod";
10
+ import type { PublicFormResponse, FormField } from "@/api/types";
11
+
12
+ // Form context types
13
+ interface LubFormContextValue {
14
+ form: PublicFormResponse;
15
+ fields: FormField[];
16
+ currentStep: number;
17
+ totalSteps: number;
18
+ isMultiStep: boolean;
19
+ isSubmitting: boolean;
20
+ isSuccess: boolean;
21
+ error: string | null;
22
+ goToStep: (step: number) => void;
23
+ nextStep: () => void;
24
+ prevStep: () => void;
25
+ getFieldsForCurrentStep: () => FormField[];
26
+ getVisibleFields: (formValues: FieldValues) => FormField[];
27
+ }
28
+
29
+ const LubFormContext = createContext<LubFormContextValue | null>(null);
30
+
31
+ export function useLubFormContext(): LubFormContextValue {
32
+ const context = useContext(LubFormContext);
33
+ if (!context) {
34
+ throw new Error("useLubFormContext must be used within a LubFormProvider");
35
+ }
36
+ return context;
37
+ }
38
+
39
+ // Props
40
+ interface LubFormProviderProps {
41
+ form: PublicFormResponse;
42
+ schema: z.ZodSchema;
43
+ defaultValues?: Record<string, unknown>;
44
+ currentStep: number;
45
+ totalSteps: number;
46
+ isSubmitting: boolean;
47
+ isSuccess: boolean;
48
+ error: string | null;
49
+ onStepChange: (step: number) => void;
50
+ children: ReactNode;
51
+ }
52
+
53
+ export function LubFormProvider({
54
+ form,
55
+ schema,
56
+ defaultValues,
57
+ currentStep,
58
+ totalSteps,
59
+ isSubmitting,
60
+ isSuccess,
61
+ error,
62
+ onStepChange,
63
+ children,
64
+ }: LubFormProviderProps) {
65
+ const methods = useForm({
66
+ resolver: zodResolver(schema),
67
+ defaultValues: defaultValues ?? {},
68
+ mode: "onBlur",
69
+ reValidateMode: "onChange",
70
+ });
71
+
72
+ const isMultiStep = form.is_multi_step && (form.steps?.length ?? 0) > 0;
73
+
74
+ const goToStep = (step: number) => {
75
+ if (step >= 0 && step < totalSteps) {
76
+ onStepChange(step);
77
+ }
78
+ };
79
+
80
+ const nextStep = () => {
81
+ if (currentStep < totalSteps - 1) {
82
+ onStepChange(currentStep + 1);
83
+ }
84
+ };
85
+
86
+ const prevStep = () => {
87
+ if (currentStep > 0) {
88
+ onStepChange(currentStep - 1);
89
+ }
90
+ };
91
+
92
+ const getFieldsForCurrentStep = (): FormField[] => {
93
+ if (!isMultiStep || !form.steps) {
94
+ return form.fields.filter((f) => f.is_active);
95
+ }
96
+
97
+ const step = form.steps[currentStep];
98
+ if (!step) return [];
99
+
100
+ const stepFieldIds = new Set(step.field_ids);
101
+ return form.fields.filter((f) => f.is_active && stepFieldIds.has(f.id));
102
+ };
103
+
104
+ const getVisibleFields = (formValues: FieldValues): FormField[] => {
105
+ const stepFields = getFieldsForCurrentStep();
106
+ return stepFields.filter((field) => {
107
+ if (!field.conditional_logic) return true;
108
+ return evaluateFieldVisibility(field, formValues);
109
+ });
110
+ };
111
+
112
+ const contextValue = useMemo(
113
+ (): LubFormContextValue => ({
114
+ form,
115
+ fields: form.fields,
116
+ currentStep,
117
+ totalSteps,
118
+ isMultiStep,
119
+ isSubmitting,
120
+ isSuccess,
121
+ error,
122
+ goToStep,
123
+ nextStep,
124
+ prevStep,
125
+ getFieldsForCurrentStep,
126
+ getVisibleFields,
127
+ }),
128
+ [form, currentStep, totalSteps, isSubmitting, isSuccess, error],
129
+ );
130
+
131
+ return (
132
+ <LubFormContext.Provider value={contextValue}>
133
+ <RHFFormProvider {...methods}>{children}</RHFFormProvider>
134
+ </LubFormContext.Provider>
135
+ );
136
+ }
137
+
138
+ // Helper to evaluate field visibility based on conditional logic
139
+ function evaluateFieldVisibility(
140
+ field: FormField,
141
+ formValues: FieldValues,
142
+ ): boolean {
143
+ const logic = field.conditional_logic;
144
+ if (!logic) return true;
145
+
146
+ // Check show_if
147
+ if (logic.show_if) {
148
+ return evaluateCondition(logic.show_if, formValues);
149
+ }
150
+
151
+ // Check hide_if
152
+ if (logic.hide_if) {
153
+ return !evaluateCondition(logic.hide_if, formValues);
154
+ }
155
+
156
+ return true;
157
+ }
158
+
159
+ function evaluateCondition(
160
+ condition: NonNullable<FormField["conditional_logic"]>["show_if"],
161
+ formValues: FieldValues,
162
+ ): boolean {
163
+ if (!condition) return true;
164
+
165
+ const fieldValue = formValues[condition.field_name];
166
+ let result = compareValues(fieldValue, condition.operator, condition.value);
167
+
168
+ // Evaluate AND conditions
169
+ if (condition.and && condition.and.length > 0) {
170
+ for (const andCond of condition.and) {
171
+ if (!evaluateCondition(andCond, formValues)) {
172
+ return false;
173
+ }
174
+ }
175
+ }
176
+
177
+ // Evaluate OR conditions
178
+ if (condition.or && condition.or.length > 0) {
179
+ let orResult = false;
180
+ for (const orCond of condition.or) {
181
+ if (evaluateCondition(orCond, formValues)) {
182
+ orResult = true;
183
+ break;
184
+ }
185
+ }
186
+ result = result && orResult;
187
+ }
188
+
189
+ return result;
190
+ }
191
+
192
+ function compareValues(
193
+ fieldValue: unknown,
194
+ operator: string,
195
+ compareValue: unknown,
196
+ ): boolean {
197
+ switch (operator) {
198
+ case "equals":
199
+ return fieldValue === compareValue;
200
+ case "not_equals":
201
+ return fieldValue !== compareValue;
202
+ case "contains":
203
+ return String(fieldValue ?? "").includes(String(compareValue));
204
+ case "not_contains":
205
+ return !String(fieldValue ?? "").includes(String(compareValue));
206
+ case "greater_than":
207
+ return Number(fieldValue) > Number(compareValue);
208
+ case "less_than":
209
+ return Number(fieldValue) < Number(compareValue);
210
+ case "is_empty":
211
+ return fieldValue == null || fieldValue === "";
212
+ case "is_not_empty":
213
+ return fieldValue != null && fieldValue !== "";
214
+ default:
215
+ return true;
216
+ }
217
+ }
218
+
219
+ // Export React Hook Form methods hook
220
+ export function useFormMethods(): UseFormReturn {
221
+ const methods = useContext(
222
+ RHFFormProvider as unknown as React.Context<UseFormReturn | null>,
223
+ );
224
+ if (!methods) {
225
+ throw new Error("useFormMethods must be used within a LubFormProvider");
226
+ }
227
+ return methods;
228
+ }