keystone-design-bootstrap 1.0.21 → 1.0.24

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 (28) hide show
  1. package/package.json +2 -1
  2. package/src/design_system/components/DynamicFormFields.tsx +202 -0
  3. package/src/design_system/elements/checkbox/privacy-checkbox.tsx +41 -0
  4. package/src/design_system/elements/index.tsx +4 -1
  5. package/src/design_system/elements/input/input-group.tsx +11 -16
  6. package/src/design_system/elements/select/select-native.tsx +6 -9
  7. package/src/design_system/sections/contact-section-form.aman.tsx +90 -0
  8. package/src/design_system/sections/contact-section-form.barelux.tsx +92 -0
  9. package/src/design_system/sections/contact-section-form.tsx +35 -284
  10. package/src/design_system/sections/contact-section.aman.tsx +10 -24
  11. package/src/design_system/sections/contact-section.barelux.tsx +7 -46
  12. package/src/design_system/sections/contact-section.tsx +6 -4
  13. package/src/design_system/sections/index.tsx +2 -0
  14. package/src/design_system/sections/job-application-form.aman.tsx +96 -0
  15. package/src/design_system/sections/job-application-form.barelux.tsx +96 -0
  16. package/src/design_system/sections/job-application-form.tsx +28 -193
  17. package/src/design_system/sections/job-detail-section.aman.tsx +5 -2
  18. package/src/design_system/sections/job-detail-section.barelux.tsx +5 -2
  19. package/src/design_system/sections/job-detail-section.tsx +5 -1
  20. package/src/design_system/sections/policy-document-section.tsx +46 -0
  21. package/src/lib/actions.ts +11 -9
  22. package/src/lib/server-api.ts +19 -0
  23. package/src/tracking/MetaPixel.tsx +54 -0
  24. package/src/tracking/index.ts +3 -0
  25. package/src/tracking/trackMetaLead.ts +9 -0
  26. package/src/types/api/form.ts +29 -0
  27. package/src/types/index.ts +1 -0
  28. package/src/utils/countries.tsx +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.21",
3
+ "version": "1.0.24",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -12,6 +12,7 @@
12
12
  "./logo": "./src/design_system/logo/keystone-logo.tsx",
13
13
  "./hooks": "./src/lib/hooks/index.ts",
14
14
  "./contexts": "./src/contexts/index.ts",
15
+ "./tracking": "./src/tracking/index.ts",
15
16
  "./lib/server-api": "./src/lib/server-api.ts",
16
17
  "./lib/component-registry": "./src/lib/component-registry.ts",
17
18
  "./styles/*": "./src/styles/*",
@@ -0,0 +1,202 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { Input, InputBase, InputGroup, NativeSelect, Textarea, PrivacyCheckbox } from '../elements';
5
+ import type { FormDefinition, FormFieldDefinition } from '../../types/api/form';
6
+ import countries from '../../utils/countries';
7
+
8
+ export interface DynamicFormFieldsProps {
9
+ /** Form definition from API (fields array + optional settings). */
10
+ form: FormDefinition;
11
+ /** For job_application forms: add hidden jobSlug input. */
12
+ jobSlug?: string;
13
+ }
14
+
15
+ const INPUT_TYPES = ['text', 'email', 'tel'] as const;
16
+
17
+ /** Get national-format mask from country (e.g. "(###) ###-####") by stripping country code from phoneMask. */
18
+ function getNationalMask(country: (typeof countries)[0] | undefined): string {
19
+ if (!country?.phoneMask) return '';
20
+ const code = country.phoneCode.startsWith('+') ? country.phoneCode : `+${country.phoneCode}`;
21
+ const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
+ return country.phoneMask.replace(new RegExp(`^\\s*${escaped}[\\s-]*`), '').trim();
23
+ }
24
+
25
+ /** Format digit string into mask; # = one digit. No trailing literals so backspace works. */
26
+ function formatDigitsToMask(digits: string, mask: string): string {
27
+ if (digits.length === 0) return '';
28
+ let i = 0;
29
+ let out = '';
30
+ for (const c of mask) {
31
+ if (c === '#') {
32
+ if (i < digits.length) out += digits[i++];
33
+ else break;
34
+ } else if (i < digits.length) {
35
+ out += c;
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function allFieldsFlat(fields: FormDefinition['fields']): FormFieldDefinition[] {
42
+ const out: FormFieldDefinition[] = [];
43
+ for (const item of fields) {
44
+ if (Array.isArray(item)) out.push(...item);
45
+ else if (item && typeof item === 'object' && 'name' in item) out.push(item);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function renderField(
51
+ field: FormFieldDefinition,
52
+ index: number,
53
+ showCountryCode: boolean,
54
+ selectedCountryPhone: string,
55
+ onCountryChange: (code: string) => void,
56
+ phoneValues: Record<string, string>,
57
+ setPhoneValues: React.Dispatch<React.SetStateAction<Record<string, string>>>
58
+ ): React.ReactNode {
59
+ const name = field.name ?? `field-${index}`;
60
+
61
+ if (field.type === 'hidden') {
62
+ const val = field.value ?? '';
63
+ return <input key={name} type="hidden" name={name} value={val} />;
64
+ }
65
+
66
+ if (field.type === 'tel' && showCountryCode) {
67
+ const countryOptions = countries.map((c) => ({
68
+ label: c.phoneCode.startsWith('+') ? c.phoneCode : `+${c.phoneCode}`,
69
+ value: c.code,
70
+ }));
71
+ const country = countries.find((c) => c.code === selectedCountryPhone);
72
+ const nationalMask = getNationalMask(country);
73
+ const value = phoneValues[name] ?? '';
74
+ const placeholder = nationalMask ? nationalMask.replace(/#/g, '0') : (field.placeholder ?? '');
75
+
76
+ const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
77
+ const digits = e.target.value.replace(/\D/g, '');
78
+ const formatted = nationalMask ? formatDigitsToMask(digits, nationalMask) : digits;
79
+ setPhoneValues((prev) => ({ ...prev, [name]: formatted }));
80
+ };
81
+
82
+ return (
83
+ <InputGroup
84
+ key={name}
85
+ label={field.label}
86
+ isRequired={Boolean(field.required)}
87
+ size="md"
88
+ leadingAddon={
89
+ <NativeSelect
90
+ aria-label="Country code"
91
+ value={selectedCountryPhone}
92
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
93
+ onCountryChange(e.currentTarget.value)
94
+ }
95
+ options={countryOptions}
96
+ />
97
+ }
98
+ >
99
+ <InputBase
100
+ type="tel"
101
+ name={name}
102
+ value={value}
103
+ onChange={handlePhoneChange}
104
+ placeholder={placeholder}
105
+ size="md"
106
+ />
107
+ </InputGroup>
108
+ );
109
+ }
110
+
111
+ if (field.type === 'textarea') {
112
+ return (
113
+ <Textarea
114
+ key={name}
115
+ name={name}
116
+ label={field.label}
117
+ placeholder={field.placeholder}
118
+ rows={6}
119
+ isRequired={Boolean(field.required)}
120
+ />
121
+ );
122
+ }
123
+
124
+ const inputType = INPUT_TYPES.includes(field.type as (typeof INPUT_TYPES)[number])
125
+ ? (field.type as 'text' | 'email' | 'tel')
126
+ : 'text';
127
+ return (
128
+ <Input
129
+ key={name}
130
+ isRequired={Boolean(field.required)}
131
+ size="md"
132
+ name={name}
133
+ label={field.label}
134
+ type={inputType}
135
+ placeholder={field.placeholder}
136
+ />
137
+ );
138
+ }
139
+
140
+ export function DynamicFormFields({ form, jobSlug }: DynamicFormFieldsProps) {
141
+ const [selectedCountryPhone, setSelectedCountryPhone] = useState('US');
142
+ const [phoneValues, setPhoneValues] = useState<Record<string, string>>({});
143
+ const { settings } = form;
144
+ const fields: FormDefinition['fields'] = Array.isArray(form.fields) ? form.fields : [];
145
+
146
+ const handleCountryChange = (newCode: string) => {
147
+ setSelectedCountryPhone(newCode);
148
+ const country = countries.find((c) => c.code === newCode);
149
+ const nationalMask = getNationalMask(country);
150
+ if (!nationalMask) return;
151
+ setPhoneValues((prev) => {
152
+ const next = { ...prev };
153
+ let changed = false;
154
+ for (const name of Object.keys(next)) {
155
+ const digits = next[name].replace(/\D/g, '');
156
+ const formatted = formatDigitsToMask(digits, nationalMask);
157
+ if (formatted !== next[name]) {
158
+ next[name] = formatted;
159
+ changed = true;
160
+ }
161
+ }
162
+ return changed ? next : prev;
163
+ });
164
+ };
165
+
166
+ /** Show country code selector for tel fields; default true (US +1) unless backend sets to false. */
167
+ const showCountryCode = settings?.show_country_code !== false;
168
+ const showPrivacyCheckbox = settings?.show_privacy_checkbox !== false;
169
+ const flat = allFieldsFlat(fields);
170
+ const hasPhoneField = flat.some((f) => f.type === 'tel');
171
+
172
+ return (
173
+ <div className="flex flex-col gap-6">
174
+ {fields.map((item, index) => {
175
+ if (Array.isArray(item)) {
176
+ if (item.length === 0) return null;
177
+ return (
178
+ <div
179
+ key={`row-${index}`}
180
+ className="flex flex-col gap-x-8 gap-y-6 md:flex-row"
181
+ >
182
+ {item.map((f, i) => (
183
+ <div key={f.name} className="flex-1">
184
+ {renderField(f, i, showCountryCode, selectedCountryPhone, handleCountryChange, phoneValues, setPhoneValues)}
185
+ </div>
186
+ ))}
187
+ </div>
188
+ );
189
+ }
190
+ return (
191
+ <div key={item.name ?? `field-${index}`}>
192
+ {renderField(item, index, showCountryCode, selectedCountryPhone, handleCountryChange, phoneValues, setPhoneValues)}
193
+ </div>
194
+ );
195
+ })}
196
+
197
+ {jobSlug ? <input type="hidden" name="jobSlug" value={jobSlug} /> : null}
198
+
199
+ {showPrivacyCheckbox && hasPhoneField && <PrivacyCheckbox />}
200
+ </div>
201
+ );
202
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useId } from 'react';
4
+
5
+ /**
6
+ * A2P 10DLC-compliant privacy + SMS consent checkbox. Use on any form that collects phone numbers.
7
+ * Same copy and markup used across contact and job application forms.
8
+ */
9
+ export function PrivacyCheckbox() {
10
+ const id = useId();
11
+ return (
12
+ <div className="flex items-start gap-3">
13
+ <input
14
+ type="checkbox"
15
+ id={id}
16
+ name="privacy"
17
+ required
18
+ aria-label="Agree to Privacy Policy, Terms of Service, and SMS consent"
19
+ className="mt-1 w-4 h-4 shrink-0 border-secondary rounded focus:ring-focus-ring"
20
+ />
21
+ <label htmlFor={id} className="font-body text-sm text-tertiary">
22
+ By checking this box, you agree to our{' '}
23
+ <a
24
+ href="/privacy-policy"
25
+ className="underline outline-focus-ring focus-visible:outline-2 focus-visible:outline-offset-2"
26
+ >
27
+ Privacy Policy
28
+ </a>
29
+ {' '}
30
+ and{' '}
31
+ <a
32
+ href="/terms-of-service"
33
+ className="underline outline-focus-ring focus-visible:outline-2 focus-visible:outline-offset-2"
34
+ >
35
+ Terms of Service
36
+ </a>
37
+ . You consent to receive text messages from our team and our automated systems (e.g., question follow-ups, updates, responses to ads) at the number provided. Message frequency varies. Message and data rates may apply. Reply HELP for help, STOP to opt out. *
38
+ </label>
39
+ </div>
40
+ );
41
+ }
@@ -20,10 +20,11 @@ import { AppStoreButton as BaseAppStoreButton, GooglePlayButton as BaseGooglePla
20
20
  import { ButtonGroup as BaseButtonGroup } from './button-group/button-group';
21
21
 
22
22
  // Form inputs
23
- import { Input as BaseInput } from './input/input';
23
+ import { Input as BaseInput, InputBase as BaseInputBase } from './input/input';
24
24
  import { InputGroup as BaseInputGroup } from './input/input-group';
25
25
  import { TextArea as BaseTextarea } from './textarea/textarea';
26
26
  import { Checkbox as BaseCheckbox } from './checkbox/checkbox';
27
+ import { PrivacyCheckbox as BasePrivacyCheckbox } from './checkbox/privacy-checkbox';
27
28
  import { Toggle as BaseToggle } from './toggle/toggle';
28
29
  import { Form as BaseForm } from './form/form';
29
30
  import { FormContainer as BaseFormContainer } from './form-container/form-container';
@@ -106,8 +107,10 @@ export const ButtonGroup = createThemedExport('button-group', BaseButtonGroup);
106
107
 
107
108
  // Form inputs
108
109
  export const Input = createThemedExport('input', BaseInput);
110
+ export const InputBase = BaseInputBase;
109
111
  export const Textarea = createThemedExport('textarea', BaseTextarea);
110
112
  export const Checkbox = createThemedExport('checkbox', BaseCheckbox);
113
+ export const PrivacyCheckbox = BasePrivacyCheckbox;
111
114
  export const Toggle = createThemedExport('toggle', BaseToggle);
112
115
  export const Form = createThemedExport('form', BaseForm);
113
116
  export const FormContainer = createThemedExport('form-container', BaseFormContainer as unknown as React.ComponentType<Record<string, unknown>>);
@@ -58,7 +58,6 @@ export const InputGroup = ({ size = "sm", prefix, leadingAddon, trailingAddon, l
58
58
  const paddings = sortCx({
59
59
  sm: {
60
60
  input: cx(
61
- // Apply padding styles when select element is passed as a child
62
61
  hasLeading && "group-has-[&>select]:px-2.5 group-has-[&>select]:pl-2.5",
63
62
  hasTrailing && (prefix ? "group-has-[&>select]:pr-6 group-has-[&>select]:pl-0" : "group-has-[&>select]:pr-6 group-has-[&>select]:pl-3"),
64
63
  ),
@@ -66,7 +65,6 @@ export const InputGroup = ({ size = "sm", prefix, leadingAddon, trailingAddon, l
66
65
  },
67
66
  md: {
68
67
  input: cx(
69
- // Apply padding styles when select element is passed as a child
70
68
  hasLeading && "group-has-[&>select]:px-3 group-has-[&>select]:pl-3",
71
69
  hasTrailing && (prefix ? "group-has-[&>select]:pr-6 group-has-[&>select]:pl-0" : "group-has-[&>select]:pr-6 group-has-[&>select]:pl-3"),
72
70
  ),
@@ -81,14 +79,9 @@ export const InputGroup = ({ size = "sm", prefix, leadingAddon, trailingAddon, l
81
79
  inputClassName={cx(paddings[size].input)}
82
80
  tooltipClassName={cx(hasTrailing && !hasLeading && "group-has-[&>select]:right-0")}
83
81
  wrapperClassName={cx(
84
- "z-10",
85
- // Apply styles based on the presence of leading or trailing elements
86
- hasLeading && "rounded-l-none",
82
+ "z-10 min-w-0 flex-1 !w-auto",
83
+ hasLeading && "rounded-sm rounded-l-none bg-white ring-1 ring-secondary ring-inset focus-within:ring-2 focus-within:ring-brand group-disabled:bg-disabled_subtle group-disabled:ring-disabled",
87
84
  hasTrailing && "rounded-r-none",
88
- // When select element is passed as a child
89
- "group-has-[&>select]:bg-transparent group-has-[&>select]:shadow-none group-has-[&>select]:ring-0 group-has-[&>select]:focus-within:ring-0",
90
- // In `Input` component, there is "group-disabled" class so here we need to use "group-disabled:group-has-[&>select]" to avoid conflict
91
- "group-disabled:group-has-[&>select]:bg-transparent",
92
85
  )}
93
86
  {...props}
94
87
  >
@@ -98,17 +91,19 @@ export const InputGroup = ({ size = "sm", prefix, leadingAddon, trailingAddon, l
98
91
 
99
92
  <div
100
93
  data-input-size={size}
94
+ data-input-wrapper
101
95
  className={cx(
102
- "group relative flex h-max w-full flex-row justify-center rounded-lg bg-primary transition-all duration-100 ease-linear",
103
-
104
- // Only apply focus ring when child is select and input is focused
105
- "has-[&>select]:shadow-xs has-[&>select]:ring-1 has-[&>select]:ring-border-primary has-[&>select]:ring-inset has-[&>select]:has-[input:focus]:ring-2 has-[&>select]:has-[input:focus]:ring-border-brand",
106
-
96
+ "group relative flex h-max w-full flex-row items-center rounded-sm bg-white transition-all duration-100 ease-linear",
97
+ "has-[&>select]:shadow-xs has-[&>select]:ring-1 has-[&>select]:ring-secondary has-[&>select]:ring-inset has-[&>select]:has-[input:focus]:ring-2 has-[&>select]:has-[input:focus]:ring-brand",
107
98
  isDisabled && "cursor-not-allowed has-[&>select]:bg-disabled_subtle has-[&>select]:ring-border-disabled",
108
99
  isInvalid && "has-[&>select]:ring-border-error_subtle has-[&>select]:has-[input:focus]:ring-border-error",
109
100
  )}
110
101
  >
111
- {leadingAddon && <section data-leading={hasLeading || undefined}>{leadingAddon}</section>}
102
+ {leadingAddon && (
103
+ <div data-leading={hasLeading || undefined} className="flex shrink-0">
104
+ {leadingAddon}
105
+ </div>
106
+ )}
112
107
 
113
108
  {prefix && (
114
109
  <span className={cx("my-auto grow pr-2", paddings[size].leadingText)}>
@@ -118,7 +113,7 @@ export const InputGroup = ({ size = "sm", prefix, leadingAddon, trailingAddon, l
118
113
 
119
114
  {children}
120
115
 
121
- {trailingAddon && <section data-trailing={hasTrailing || undefined}>{trailingAddon}</section>}
116
+ {trailingAddon && <div data-trailing={hasTrailing || undefined}>{trailingAddon}</div>}
122
117
  </div>
123
118
 
124
119
  {hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
@@ -34,15 +34,12 @@ export const NativeSelect = ({ label, hint, options, className, selectClassName,
34
34
  aria-labelledby={selectId}
35
35
  className={cx(
36
36
  "appearance-none rounded-lg bg-primary px-3.5 py-2.5 text-md font-medium text-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset placeholder:text-fg-quaternary focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:bg-disabled_subtle disabled:text-disabled",
37
- // Styles when the select is within an `InputGroup`
38
- "in-data-input-wrapper:flex in-data-input-wrapper:h-full in-data-input-wrapper:gap-1 in-data-input-wrapper:bg-inherit in-data-input-wrapper:px-3 in-data-input-wrapper:py-2 in-data-input-wrapper:font-normal in-data-input-wrapper:text-tertiary in-data-input-wrapper:shadow-none in-data-input-wrapper:ring-transparent",
39
- // Styles for the select when `TextField` is disabled
40
- "in-data-input-wrapper:group-disabled:pointer-events-none in-data-input-wrapper:group-disabled:cursor-not-allowed in-data-input-wrapper:group-disabled:bg-transparent in-data-input-wrapper:group-disabled:text-disabled",
41
- // Common styles for sizes and border radius within `InputGroup`
42
- "in-data-input-wrapper:in-data-leading:rounded-r-none in-data-input-wrapper:in-data-trailing:rounded-l-none in-data-input-wrapper:in-data-[input-size=md]:py-2.5 in-data-input-wrapper:in-data-leading:in-data-[input-size=md]:pl-3.5 in-data-input-wrapper:in-data-[input-size=sm]:py-2 in-data-input-wrapper:in-data-[input-size=sm]:pl-3",
43
- // For "leading" dropdown within `InputGroup`
37
+ /* InputGroup: match other inputs (bg-white, rounded-sm, ring-secondary) */
38
+ "in-data-input-wrapper:flex in-data-input-wrapper:h-full in-data-input-wrapper:gap-1 in-data-input-wrapper:!bg-white in-data-input-wrapper:px-3 in-data-input-wrapper:py-2 in-data-input-wrapper:font-normal in-data-input-wrapper:text-tertiary in-data-input-wrapper:shadow-none in-data-input-wrapper:ring-1 in-data-input-wrapper:ring-secondary in-data-input-wrapper:ring-inset in-data-input-wrapper:rounded-sm in-data-input-wrapper:focus-visible:ring-2 in-data-input-wrapper:focus-visible:ring-brand",
39
+ "in-data-input-wrapper:group-disabled:pointer-events-none in-data-input-wrapper:group-disabled:cursor-not-allowed in-data-input-wrapper:group-disabled:bg-transparent in-data-input-wrapper:group-disabled:ring-disabled in-data-input-wrapper:group-disabled:text-disabled",
40
+ "in-data-input-wrapper:in-data-leading:rounded-l-sm in-data-input-wrapper:in-data-leading:rounded-r-none in-data-input-wrapper:in-data-trailing:rounded-r-sm in-data-input-wrapper:in-data-trailing:rounded-l-none",
41
+ "in-data-input-wrapper:in-data-[input-size=md]:py-2.5 in-data-input-wrapper:in-data-leading:in-data-[input-size=md]:pl-3.5 in-data-input-wrapper:in-data-[input-size=sm]:py-2 in-data-input-wrapper:in-data-[input-size=sm]:pl-3",
44
42
  "in-data-input-wrapper:in-data-leading:in-data-[input-size=md]:pr-4.5 in-data-input-wrapper:in-data-leading:in-data-[input-size=sm]:pr-4.5",
45
- // For "trailing" dropdown within `InputGroup`
46
43
  "in-data-input-wrapper:in-data-trailing:in-data-[input-size=md]:pr-8 in-data-input-wrapper:in-data-trailing:in-data-[input-size=sm]:pr-7.5",
47
44
  selectClassName,
48
45
  )}
@@ -55,7 +52,7 @@ export const NativeSelect = ({ label, hint, options, className, selectClassName,
55
52
  </select>
56
53
  <ChevronDown
57
54
  aria-hidden="true"
58
- className="pointer-events-none absolute right-3.5 size-5 text-fg-quaternary in-data-input-wrapper:right-0 in-data-input-wrapper:size-4 in-data-input-wrapper:stroke-[2.625px] in-data-input-wrapper:in-data-trailing:in-data-[input-size=sm]:right-3"
55
+ className="pointer-events-none absolute right-3.5 size-5 text-fg-quaternary in-data-input-wrapper:right-1.5 in-data-input-wrapper:size-4 in-data-input-wrapper:stroke-[2.625px] in-data-input-wrapper:in-data-trailing:in-data-[input-size=sm]:right-3"
59
56
  />
60
57
  </div>
61
58
 
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import React, { useRef, useState } from 'react';
4
+ import { Form, Button } from '../elements';
5
+ import { DynamicFormFields } from '../components/DynamicFormFields';
6
+ import { trackMetaLead } from '../../tracking/trackMetaLead';
7
+ import type { FormDefinition } from '../../types/api/form';
8
+
9
+ interface ContactSectionFormAmanProps {
10
+ /** Form fields are rendered from this definition (required). */
11
+ formDefinition: FormDefinition | null | undefined;
12
+ submitButtonText?: string;
13
+ successMessage?: string;
14
+ thankYouMessage?: string;
15
+ onSuccess?: () => void;
16
+ }
17
+
18
+ export const ContactSectionForm = ({
19
+ formDefinition,
20
+ submitButtonText = "Send message",
21
+ successMessage = "Thank you for contacting us! We'll get back to you soon.",
22
+ thankYouMessage,
23
+ onSuccess,
24
+ }: ContactSectionFormAmanProps) => {
25
+ const [isSubmitting, setIsSubmitting] = useState(false);
26
+ const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
27
+ const [statusMessage, setStatusMessage] = useState<string>('');
28
+ const formRef = useRef<HTMLFormElement>(null);
29
+
30
+ const hasFields = formDefinition != null && Array.isArray(formDefinition.fields) && formDefinition.fields.length > 0;
31
+
32
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
33
+ e.preventDefault();
34
+ setIsSubmitting(true);
35
+ setSubmitStatus('idle');
36
+ setStatusMessage('');
37
+ const formData = new FormData(e.currentTarget);
38
+ const data: Record<string, string> = { formType: 'lead' };
39
+ formData.forEach((value, key) => {
40
+ if (key !== 'privacy' && typeof value === 'string') data[key] = value;
41
+ });
42
+ try {
43
+ const response = await fetch('/api/form', {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify(data),
47
+ });
48
+ const result = await response.json();
49
+ if (result.success) {
50
+ setSubmitStatus('success');
51
+ setStatusMessage(result.message || successMessage);
52
+ formRef.current?.reset();
53
+ onSuccess?.();
54
+ trackMetaLead(result.eventId);
55
+ setTimeout(() => setSubmitStatus('idle'), 5000);
56
+ } else {
57
+ setSubmitStatus('error');
58
+ setStatusMessage(result.error || 'Something went wrong. Please try again.');
59
+ }
60
+ } catch {
61
+ setSubmitStatus('error');
62
+ setStatusMessage('Network error. Please try again later.');
63
+ }
64
+ setIsSubmitting(false);
65
+ };
66
+
67
+ if (!hasFields) return null;
68
+
69
+ return (
70
+ <Form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-6">
71
+ <DynamicFormFields form={formDefinition} />
72
+ <Button
73
+ type="submit"
74
+ color="primary"
75
+ size="xl"
76
+ className="w-full font-body text-base uppercase tracking-wide rounded-sm"
77
+ isDisabled={isSubmitting}
78
+ isLoading={isSubmitting}
79
+ >
80
+ {isSubmitting ? 'Sending...' : submitButtonText}
81
+ </Button>
82
+ {submitStatus === 'success' && (
83
+ <div className="rounded-sm bg-success-50 p-4 text-success-700 font-body">{thankYouMessage ?? statusMessage}</div>
84
+ )}
85
+ {submitStatus === 'error' && (
86
+ <div className="rounded-sm bg-error-50 p-4 text-error-700 font-body text-sm">{statusMessage}</div>
87
+ )}
88
+ </Form>
89
+ );
90
+ };
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import React, { useRef, useState } from 'react';
4
+ import { Form, Button } from '../elements';
5
+ import { DynamicFormFields } from '../components/DynamicFormFields';
6
+ import { trackMetaLead } from '../../tracking/trackMetaLead';
7
+ import type { FormDefinition } from '../../types/api/form';
8
+
9
+ interface ContactSectionFormBareluxProps {
10
+ /** Form fields are rendered from this definition (required). */
11
+ formDefinition: FormDefinition | null | undefined;
12
+ submitButtonText?: string;
13
+ successMessage?: string;
14
+ thankYouMessage?: string;
15
+ onSuccess?: () => void;
16
+ }
17
+
18
+ export const ContactSectionForm = ({
19
+ formDefinition,
20
+ submitButtonText = "Send message",
21
+ successMessage = "Thank you for contacting us! We'll get back to you soon.",
22
+ thankYouMessage,
23
+ onSuccess,
24
+ }: ContactSectionFormBareluxProps) => {
25
+ const [isSubmitting, setIsSubmitting] = useState(false);
26
+ const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
27
+ const [statusMessage, setStatusMessage] = useState<string>('');
28
+ const formRef = useRef<HTMLFormElement>(null);
29
+
30
+ const hasFields = formDefinition != null && Array.isArray(formDefinition.fields) && formDefinition.fields.length > 0;
31
+
32
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
33
+ e.preventDefault();
34
+ setIsSubmitting(true);
35
+ setSubmitStatus('idle');
36
+ setStatusMessage('');
37
+ const formData = new FormData(e.currentTarget);
38
+ const data: Record<string, string> = { formType: 'lead' };
39
+ formData.forEach((value, key) => {
40
+ if (key !== 'privacy' && typeof value === 'string') data[key] = value;
41
+ });
42
+ try {
43
+ const response = await fetch('/api/form', {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify(data),
47
+ });
48
+ const result = await response.json();
49
+ if (result.success) {
50
+ setSubmitStatus('success');
51
+ setStatusMessage(result.message || successMessage);
52
+ formRef.current?.reset();
53
+ onSuccess?.();
54
+ trackMetaLead(result.eventId);
55
+ setTimeout(() => setSubmitStatus('idle'), 5000);
56
+ } else {
57
+ setSubmitStatus('error');
58
+ setStatusMessage(result.error || 'Something went wrong. Please try again.');
59
+ }
60
+ } catch {
61
+ setSubmitStatus('error');
62
+ setStatusMessage('Network error. Please try again later.');
63
+ }
64
+ setIsSubmitting(false);
65
+ };
66
+
67
+ if (!hasFields) return null;
68
+
69
+ return (
70
+ <Form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-6">
71
+ <DynamicFormFields form={formDefinition} />
72
+ <Button
73
+ type="submit"
74
+ color="primary"
75
+ size="md"
76
+ className="w-full"
77
+ isDisabled={isSubmitting}
78
+ isLoading={isSubmitting}
79
+ >
80
+ {submitStatus === 'success' ? successMessage.split('!')[0] + '!' : (isSubmitting ? 'Sending...' : submitButtonText)}
81
+ </Button>
82
+ {submitStatus === 'success' && (
83
+ <div className="font-body text-sm text-center" style={{ color: 'var(--color-text-brand-accent)' }}>
84
+ {thankYouMessage ?? statusMessage}
85
+ </div>
86
+ )}
87
+ {submitStatus === 'error' && (
88
+ <div className="rounded-lg bg-error-50 p-4 text-error-700 font-body text-sm text-center">{statusMessage}</div>
89
+ )}
90
+ </Form>
91
+ );
92
+ };