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.
- package/package.json +2 -1
- package/src/design_system/components/DynamicFormFields.tsx +202 -0
- package/src/design_system/elements/checkbox/privacy-checkbox.tsx +41 -0
- package/src/design_system/elements/index.tsx +4 -1
- package/src/design_system/elements/input/input-group.tsx +11 -16
- package/src/design_system/elements/select/select-native.tsx +6 -9
- package/src/design_system/sections/contact-section-form.aman.tsx +90 -0
- package/src/design_system/sections/contact-section-form.barelux.tsx +92 -0
- package/src/design_system/sections/contact-section-form.tsx +35 -284
- package/src/design_system/sections/contact-section.aman.tsx +10 -24
- package/src/design_system/sections/contact-section.barelux.tsx +7 -46
- package/src/design_system/sections/contact-section.tsx +6 -4
- package/src/design_system/sections/index.tsx +2 -0
- package/src/design_system/sections/job-application-form.aman.tsx +96 -0
- package/src/design_system/sections/job-application-form.barelux.tsx +96 -0
- package/src/design_system/sections/job-application-form.tsx +28 -193
- package/src/design_system/sections/job-detail-section.aman.tsx +5 -2
- package/src/design_system/sections/job-detail-section.barelux.tsx +5 -2
- package/src/design_system/sections/job-detail-section.tsx +5 -1
- package/src/design_system/sections/policy-document-section.tsx +46 -0
- package/src/lib/actions.ts +11 -9
- package/src/lib/server-api.ts +19 -0
- package/src/tracking/MetaPixel.tsx +54 -0
- package/src/tracking/index.ts +3 -0
- package/src/tracking/trackMetaLead.ts +9 -0
- package/src/types/api/form.ts +29 -0
- package/src/types/index.ts +1 -0
- 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.
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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 && <
|
|
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
|
-
|
|
38
|
-
"in-data-input-wrapper:flex in-data-input-wrapper:h-full in-data-input-wrapper:gap-1 in-data-input-wrapper
|
|
39
|
-
|
|
40
|
-
"in-data-input-wrapper:
|
|
41
|
-
|
|
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-
|
|
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
|
+
};
|