@formsnip/embed 1.1.0 → 1.1.2
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 +1 -9
- package/dist/FormSnip.d.ts.map +1 -1
- package/dist/index.d.ts +19 -18
- package/dist/index.esm.js +214 -53
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +214 -53
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +13 -11
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/dataLayer.d.ts +2 -3
- package/dist/utils/dataLayer.d.ts.map +1 -1
- package/dist/utils/phoneCountries.d.ts +14 -0
- package/dist/utils/phoneCountries.d.ts.map +1 -0
- package/dist/utils/spamProtection.d.ts +3 -0
- package/dist/utils/spamProtection.d.ts.map +1 -0
- package/dist/utils/styles.d.ts +1 -0
- package/dist/utils/styles.d.ts.map +1 -1
- package/dist/utils/upload.d.ts +3 -3
- package/dist/utils/upload.d.ts.map +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -60,16 +60,8 @@ function App() {
|
|
|
60
60
|
| `onError` | `(error: string) => void` | No | - | Callback fired when an error occurs |
|
|
61
61
|
| `className` | `string` | No | `''` | Additional CSS class names for the form container |
|
|
62
62
|
| `prefill` | `Record<string, string>` | No | - | Prefill values keyed by each field's prefill parameter |
|
|
63
|
-
| `pushDataLayerOnSuccess` | `boolean` | No | `false` | Push to `window.dataLayer` only after the server accepts the submission |
|
|
64
|
-
| `dataLayerEventName` | `string` | No | `formsnip_submission_success` | GTM `event` name for the dataLayer push |
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
Forms use `fetch`, so GTM's default **Form Submission** trigger usually won't fire. Enable
|
|
69
|
-
`pushDataLayerOnSuccess` and add a GTM **Custom Event** trigger matching `dataLayerEventName`. The push includes
|
|
70
|
-
`formsnipFormId` and `formsnipFormName` (no field values). It runs **only** on successful API responses — not when HTML5 validation blocks submit, required file upload is missing, or the API returns an error.
|
|
71
|
-
|
|
72
|
-
You can also call `pushFormSnipSubmissionDataLayer()` yourself from `onSuccess` for full control.
|
|
64
|
+
After a **successful** server submission, the component pushes `{ event: 'formsnip_submission_success', formsnipFormId, formsnipFormName? }` to `dataLayer` (for GTM Custom Event triggers). No field values are included.
|
|
73
65
|
|
|
74
66
|
## Features
|
|
75
67
|
|
package/dist/FormSnip.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FormSnip.d.ts","sourceRoot":"","sources":["../src/FormSnip.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"FormSnip.d.ts","sourceRoot":"","sources":["../src/FormSnip.tsx"],"names":[],"mappings":"AACA,OAAO,KAA8B,MAAM,OAAO,CAAC;AACnD,OAAO,EAAQ,aAAa,EAAiC,MAAM,SAAS,CAAC;AA0C7E,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC,aAAa,CA4nB5C,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,21 +2,25 @@ import React from 'react';
|
|
|
2
2
|
|
|
3
3
|
interface FormField {
|
|
4
4
|
id: string;
|
|
5
|
-
type: 'text' | 'email' | 'textarea' | 'number' | 'date' | 'checkbox' | 'radio' | 'file' | 'phone' | 'hidden' | 'select' | 'datetime';
|
|
5
|
+
type: 'text' | 'email' | 'textarea' | 'number' | 'date' | 'checkbox' | 'radio' | 'file' | 'phone' | 'hidden' | 'select' | 'datetime' | 'time' | 'website' | 'choice' | 'heading' | 'paragraph' | 'divider';
|
|
6
6
|
label: string;
|
|
7
7
|
placeholder?: string;
|
|
8
|
+
content?: string;
|
|
8
9
|
required: boolean;
|
|
9
10
|
position: number;
|
|
10
11
|
isActive: boolean;
|
|
11
12
|
width?: 'full' | 'half';
|
|
12
13
|
options?: string[];
|
|
13
14
|
value?: string;
|
|
14
|
-
|
|
15
|
-
urlPrefillParam?: string;
|
|
15
|
+
hideLabel?: boolean;
|
|
16
16
|
}
|
|
17
17
|
interface FormValidation {
|
|
18
18
|
errorMessages?: Record<string, string>;
|
|
19
19
|
confirmationText?: string;
|
|
20
|
+
redirectUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
interface FormSpamProtection {
|
|
23
|
+
acceptingSubmissions?: boolean;
|
|
20
24
|
}
|
|
21
25
|
interface Form {
|
|
22
26
|
_id: string;
|
|
@@ -29,6 +33,7 @@ interface Form {
|
|
|
29
33
|
backgroundColor?: string;
|
|
30
34
|
color?: string;
|
|
31
35
|
borderColor?: string;
|
|
36
|
+
placeholderColor?: string;
|
|
32
37
|
borderRadius?: number;
|
|
33
38
|
paddingX?: number;
|
|
34
39
|
paddingY?: number;
|
|
@@ -47,6 +52,8 @@ interface Form {
|
|
|
47
52
|
};
|
|
48
53
|
};
|
|
49
54
|
validation?: FormValidation;
|
|
55
|
+
spamProtection?: FormSpamProtection;
|
|
56
|
+
submitToken?: string;
|
|
50
57
|
}
|
|
51
58
|
interface FormSnipProps {
|
|
52
59
|
formId: string;
|
|
@@ -54,16 +61,11 @@ interface FormSnipProps {
|
|
|
54
61
|
onSuccess?: (data: any) => void;
|
|
55
62
|
onError?: (error: string) => void;
|
|
56
63
|
className?: string;
|
|
57
|
-
/** Optional prefill values keyed by URL prefill parameter (e.g. ?tjeneste=...) */
|
|
58
|
-
prefill?: Record<string, string>;
|
|
59
64
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* matching `dataLayerEventName`.
|
|
65
|
+
* Initial values keyed like the URL query (defaults to each field’s `id`).
|
|
66
|
+
* Example: field id `email` → `prefill={{ email: 'a@b.com' }}` or `?email=a@b.com`.
|
|
63
67
|
*/
|
|
64
|
-
|
|
65
|
-
/** GTM `event` name for the dataLayer push (default: `formsnip_submission_success`). */
|
|
66
|
-
dataLayerEventName?: string;
|
|
68
|
+
prefill?: Record<string, string>;
|
|
67
69
|
}
|
|
68
70
|
interface SubmissionResponse {
|
|
69
71
|
success: boolean;
|
|
@@ -87,7 +89,7 @@ interface FileUploadProps {
|
|
|
87
89
|
}
|
|
88
90
|
declare const FileUpload: React.FC<FileUploadProps>;
|
|
89
91
|
|
|
90
|
-
declare const ALLOWED_FILE_EXTENSIONS = ".jpg,.jpeg,.png,.gif,.webp,.
|
|
92
|
+
declare const ALLOWED_FILE_EXTENSIONS = ".jpg,.jpeg,.png,.gif,.webp,.pdf,.mp4,.mpeg,.mov,.avi,.webm,.mkv,.mp3,.wav,.ogg,.aac,.flac";
|
|
91
93
|
declare const MAX_TOTAL_FILE_SIZE: number;
|
|
92
94
|
interface UploadResult {
|
|
93
95
|
success: boolean;
|
|
@@ -102,21 +104,20 @@ interface UploadMultipleResult {
|
|
|
102
104
|
/**
|
|
103
105
|
* Upload a file to R2 via presigned URL
|
|
104
106
|
*/
|
|
105
|
-
declare function uploadFile(file: File, formId: string, baseUrl: string): Promise<UploadResult>;
|
|
107
|
+
declare function uploadFile(file: File, formId: string, baseUrl: string, submitToken: string): Promise<UploadResult>;
|
|
106
108
|
/**
|
|
107
109
|
* Upload multiple files to R2
|
|
108
110
|
*/
|
|
109
|
-
declare function uploadFiles(files: File[], formId: string, baseUrl: string): Promise<UploadMultipleResult>;
|
|
111
|
+
declare function uploadFiles(files: File[], formId: string, baseUrl: string, submitToken: string): Promise<UploadMultipleResult>;
|
|
110
112
|
|
|
111
113
|
/**
|
|
112
|
-
* Push a GTM-friendly event to
|
|
113
|
-
* Safe no-op
|
|
114
|
+
* Push a GTM-friendly event to the global dataLayer after a successful submission.
|
|
115
|
+
* Uses `globalThis` so it works in typical browser bundles. Safe no-op if push throws.
|
|
114
116
|
*/
|
|
115
117
|
declare function pushFormSnipSubmissionDataLayer(options: {
|
|
116
|
-
eventName?: string;
|
|
117
118
|
formId: string;
|
|
118
119
|
formName?: string;
|
|
119
120
|
}): void;
|
|
120
121
|
|
|
121
122
|
export { ALLOWED_FILE_EXTENSIONS, FileUpload, FormSnip, MAX_TOTAL_FILE_SIZE, pushFormSnipSubmissionDataLayer, uploadFile, uploadFiles };
|
|
122
|
-
export type { Form, FormField, FormSnipProps, FormValidation, SubmissionResponse, UploadMultipleResult, UploadResult };
|
|
123
|
+
export type { Form, FormField, FormSnipProps, FormSpamProtection, FormValidation, SubmissionResponse, UploadMultipleResult, UploadResult };
|
package/dist/index.esm.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import React, { useRef, useState, useEffect } from 'react';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const HONEYPOT_FIELD = '_fs_hp';
|
|
4
|
+
const SUBMIT_TOKEN_FIELD = '_fs_token';
|
|
5
|
+
|
|
6
|
+
const ALLOWED_FILE_EXTENSIONS = '.jpg,.jpeg,.png,.gif,.webp,.pdf,.mp4,.mpeg,.mov,.avi,.webm,.mkv,.mp3,.wav,.ogg,.aac,.flac';
|
|
4
7
|
const MAX_TOTAL_FILE_SIZE = 15 * 1024 * 1024; // 15MB
|
|
5
8
|
/**
|
|
6
9
|
* Upload a file to R2 via presigned URL
|
|
7
10
|
*/
|
|
8
|
-
async function uploadFile(file, formId, baseUrl) {
|
|
11
|
+
async function uploadFile(file, formId, baseUrl, submitToken) {
|
|
9
12
|
try {
|
|
13
|
+
if (!submitToken) {
|
|
14
|
+
return {
|
|
15
|
+
success: false,
|
|
16
|
+
error: 'Form session expired. Please reload and try again.',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
10
19
|
// Step 1: Get presigned upload URL
|
|
11
20
|
const signResponse = await fetch(`${baseUrl}/uploads/sign`, {
|
|
12
21
|
method: 'POST',
|
|
@@ -19,6 +28,7 @@ async function uploadFile(file, formId, baseUrl) {
|
|
|
19
28
|
contentType: file.type,
|
|
20
29
|
size: file.size,
|
|
21
30
|
formId,
|
|
31
|
+
[SUBMIT_TOKEN_FIELD]: submitToken,
|
|
22
32
|
}),
|
|
23
33
|
});
|
|
24
34
|
if (!signResponse.ok) {
|
|
@@ -64,7 +74,7 @@ async function uploadFile(file, formId, baseUrl) {
|
|
|
64
74
|
/**
|
|
65
75
|
* Upload multiple files to R2
|
|
66
76
|
*/
|
|
67
|
-
async function uploadFiles(files, formId, baseUrl) {
|
|
77
|
+
async function uploadFiles(files, formId, baseUrl, submitToken) {
|
|
68
78
|
// Check total size
|
|
69
79
|
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
|
70
80
|
if (totalSize > MAX_TOTAL_FILE_SIZE) {
|
|
@@ -73,7 +83,7 @@ async function uploadFiles(files, formId, baseUrl) {
|
|
|
73
83
|
errors: [`Total file size exceeds 15MB limit per submission`],
|
|
74
84
|
};
|
|
75
85
|
}
|
|
76
|
-
const results = await Promise.allSettled(files.map((file) => uploadFile(file, formId, baseUrl)));
|
|
86
|
+
const results = await Promise.allSettled(files.map((file) => uploadFile(file, formId, baseUrl, submitToken)));
|
|
77
87
|
const fileKeys = [];
|
|
78
88
|
const errors = [];
|
|
79
89
|
results.forEach((result, index) => {
|
|
@@ -261,6 +271,7 @@ function injectBaseStyles() {
|
|
|
261
271
|
'.fs-space-x-2>*+*{margin-left:0.5rem}',
|
|
262
272
|
'.fs-outline-none{outline:none}',
|
|
263
273
|
'.fs-hover-opacity:hover{opacity:0.9}',
|
|
274
|
+
'.formsnip-embed-ph::placeholder{color:var(--fs-placeholder-color,#9ca3af);opacity:1}',
|
|
264
275
|
].join('');
|
|
265
276
|
const style = document.createElement('style');
|
|
266
277
|
style.id = 'formsnip-base-styles';
|
|
@@ -296,6 +307,9 @@ function applyStyles(styles, customStyles) {
|
|
|
296
307
|
if (customStyles.fontSize !== undefined) {
|
|
297
308
|
result.fontSize = `${customStyles.fontSize}px`;
|
|
298
309
|
}
|
|
310
|
+
if (customStyles.placeholderColor) {
|
|
311
|
+
result['--fs-placeholder-color'] = customStyles.placeholderColor;
|
|
312
|
+
}
|
|
299
313
|
// Note: fullWidth is handled separately in the component, not here
|
|
300
314
|
// to ensure proper display property handling
|
|
301
315
|
return result;
|
|
@@ -312,17 +326,18 @@ const CLASSES = {
|
|
|
312
326
|
};
|
|
313
327
|
|
|
314
328
|
/**
|
|
315
|
-
* Push a GTM-friendly event to
|
|
316
|
-
* Safe no-op
|
|
329
|
+
* Push a GTM-friendly event to the global dataLayer after a successful submission.
|
|
330
|
+
* Uses `globalThis` so it works in typical browser bundles. Safe no-op if push throws.
|
|
317
331
|
*/
|
|
318
332
|
function pushFormSnipSubmissionDataLayer(options) {
|
|
319
|
-
if (typeof window === 'undefined')
|
|
320
|
-
return;
|
|
321
333
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
334
|
+
if (typeof globalThis === 'undefined')
|
|
335
|
+
return;
|
|
336
|
+
const g = globalThis;
|
|
337
|
+
const layer = Array.isArray(g.dataLayer) ? g.dataLayer : [];
|
|
338
|
+
g.dataLayer = layer;
|
|
339
|
+
layer.push({
|
|
340
|
+
event: 'formsnip_submission_success',
|
|
326
341
|
formsnipFormId: options.formId,
|
|
327
342
|
...(options.formName ? { formsnipFormName: options.formName } : {}),
|
|
328
343
|
});
|
|
@@ -332,6 +347,86 @@ function pushFormSnipSubmissionDataLayer(options) {
|
|
|
332
347
|
}
|
|
333
348
|
}
|
|
334
349
|
|
|
350
|
+
const PHONE_COUNTRIES = [
|
|
351
|
+
{ iso: 'AU', name: 'Australia', dial: '+61' },
|
|
352
|
+
{ iso: 'AT', name: 'Austria', dial: '+43' },
|
|
353
|
+
{ iso: 'BE', name: 'Belgium', dial: '+32' },
|
|
354
|
+
{ iso: 'BR', name: 'Brazil', dial: '+55' },
|
|
355
|
+
{ iso: 'CA', name: 'Canada', dial: '+1' },
|
|
356
|
+
{ iso: 'CN', name: 'China', dial: '+86' },
|
|
357
|
+
{ iso: 'DK', name: 'Denmark', dial: '+45' },
|
|
358
|
+
{ iso: 'FI', name: 'Finland', dial: '+358' },
|
|
359
|
+
{ iso: 'FR', name: 'France', dial: '+33' },
|
|
360
|
+
{ iso: 'DE', name: 'Germany', dial: '+49' },
|
|
361
|
+
{ iso: 'GR', name: 'Greece', dial: '+30' },
|
|
362
|
+
{ iso: 'HK', name: 'Hong Kong', dial: '+852' },
|
|
363
|
+
{ iso: 'IS', name: 'Iceland', dial: '+354' },
|
|
364
|
+
{ iso: 'IN', name: 'India', dial: '+91' },
|
|
365
|
+
{ iso: 'IE', name: 'Ireland', dial: '+353' },
|
|
366
|
+
{ iso: 'IT', name: 'Italy', dial: '+39' },
|
|
367
|
+
{ iso: 'JP', name: 'Japan', dial: '+81' },
|
|
368
|
+
{ iso: 'LU', name: 'Luxembourg', dial: '+352' },
|
|
369
|
+
{ iso: 'MX', name: 'Mexico', dial: '+52' },
|
|
370
|
+
{ iso: 'NL', name: 'Netherlands', dial: '+31' },
|
|
371
|
+
{ iso: 'NZ', name: 'New Zealand', dial: '+64' },
|
|
372
|
+
{ iso: 'NO', name: 'Norway', dial: '+47' },
|
|
373
|
+
{ iso: 'PL', name: 'Poland', dial: '+48' },
|
|
374
|
+
{ iso: 'PT', name: 'Portugal', dial: '+351' },
|
|
375
|
+
{ iso: 'SG', name: 'Singapore', dial: '+65' },
|
|
376
|
+
{ iso: 'ZA', name: 'South Africa', dial: '+27' },
|
|
377
|
+
{ iso: 'KR', name: 'South Korea', dial: '+82' },
|
|
378
|
+
{ iso: 'ES', name: 'Spain', dial: '+34' },
|
|
379
|
+
{ iso: 'SE', name: 'Sweden', dial: '+46' },
|
|
380
|
+
{ iso: 'CH', name: 'Switzerland', dial: '+41' },
|
|
381
|
+
{ iso: 'AE', name: 'United Arab Emirates', dial: '+971' },
|
|
382
|
+
{ iso: 'GB', name: 'United Kingdom', dial: '+44' },
|
|
383
|
+
{ iso: 'US', name: 'United States', dial: '+1' },
|
|
384
|
+
];
|
|
385
|
+
const PREFERRED_ISO_FOR_DIAL = {
|
|
386
|
+
'+1': 'US',
|
|
387
|
+
'+44': 'GB',
|
|
388
|
+
};
|
|
389
|
+
function defaultPhoneIso() {
|
|
390
|
+
try {
|
|
391
|
+
const region = new Intl.Locale(navigator.language).region;
|
|
392
|
+
if (region && PHONE_COUNTRIES.some((country) => country.iso === region)) {
|
|
393
|
+
return region;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
/* ignore */
|
|
398
|
+
}
|
|
399
|
+
return 'DK';
|
|
400
|
+
}
|
|
401
|
+
function phoneCountryByIso(iso) {
|
|
402
|
+
return PHONE_COUNTRIES.find((country) => country.iso === iso) ?? PHONE_COUNTRIES.find((country) => country.iso === 'DK');
|
|
403
|
+
}
|
|
404
|
+
function formatPhoneValue(dial, national) {
|
|
405
|
+
const digits = national.replace(/[^\d\s()-]/g, '').trim();
|
|
406
|
+
if (!digits)
|
|
407
|
+
return '';
|
|
408
|
+
return `${dial} ${digits}`;
|
|
409
|
+
}
|
|
410
|
+
function parsePhoneValue(value, fallbackIso = 'DK') {
|
|
411
|
+
const trimmed = value.trim();
|
|
412
|
+
if (!trimmed.startsWith('+')) {
|
|
413
|
+
return { iso: fallbackIso, national: trimmed };
|
|
414
|
+
}
|
|
415
|
+
const match = trimmed.match(/^(\+\d{1,4})(?:\s+(.*))?$/);
|
|
416
|
+
if (!match) {
|
|
417
|
+
return { iso: fallbackIso, national: trimmed };
|
|
418
|
+
}
|
|
419
|
+
const dial = match[1];
|
|
420
|
+
const national = (match[2] ?? '').trim();
|
|
421
|
+
const matches = PHONE_COUNTRIES.filter((country) => country.dial === dial);
|
|
422
|
+
if (matches.length === 0) {
|
|
423
|
+
return { iso: fallbackIso, national: trimmed.replace(/^\+\d{1,4}\s*/, '') };
|
|
424
|
+
}
|
|
425
|
+
const preferred = PREFERRED_ISO_FOR_DIAL[dial];
|
|
426
|
+
const country = matches.find((item) => item.iso === preferred) ?? matches[0];
|
|
427
|
+
return { iso: country.iso, national };
|
|
428
|
+
}
|
|
429
|
+
|
|
335
430
|
/** Build rows from flat fields (full = own row, half = paired in row) */
|
|
336
431
|
function buildRowsFromFields(fields) {
|
|
337
432
|
const rows = [];
|
|
@@ -356,7 +451,7 @@ function buildRowsFromFields(fields) {
|
|
|
356
451
|
return rows;
|
|
357
452
|
}
|
|
358
453
|
const DEFAULT_BASE_URL = 'https://api.formsnip.com';
|
|
359
|
-
const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, className = '', prefill,
|
|
454
|
+
const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, className = '', prefill, }) => {
|
|
360
455
|
const [form, setForm] = useState(null);
|
|
361
456
|
const [formData, setFormData] = useState({});
|
|
362
457
|
const [selectedFiles, setSelectedFiles] = useState({});
|
|
@@ -367,6 +462,8 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
367
462
|
const [submitError, setSubmitError] = useState(null);
|
|
368
463
|
const [submitSuccess, setSubmitSuccess] = useState(false);
|
|
369
464
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
465
|
+
const [submitToken, setSubmitToken] = useState('');
|
|
466
|
+
const [honeypot, setHoneypot] = useState('');
|
|
370
467
|
useEffect(() => {
|
|
371
468
|
// Inject base styles on mount (same as embed script)
|
|
372
469
|
injectBaseStyles();
|
|
@@ -381,6 +478,7 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
381
478
|
// Form data includes embedStyles from the database
|
|
382
479
|
// These styles are set in the EmbedForm page and stored in the Form model
|
|
383
480
|
setForm(json.data);
|
|
481
|
+
setSubmitToken(json.data.submitToken || '');
|
|
384
482
|
// Parse URL query once for prefill support
|
|
385
483
|
let urlParams = null;
|
|
386
484
|
if (typeof window !== 'undefined') {
|
|
@@ -393,22 +491,27 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
393
491
|
return acc;
|
|
394
492
|
}
|
|
395
493
|
let initialValue = '';
|
|
396
|
-
const prefillKey = field.
|
|
494
|
+
const prefillKey = field.id;
|
|
397
495
|
const canPrefillType = field.type === 'text' ||
|
|
398
496
|
field.type === 'email' ||
|
|
399
497
|
field.type === 'textarea' ||
|
|
400
498
|
field.type === 'number' ||
|
|
401
499
|
field.type === 'date' ||
|
|
402
500
|
field.type === 'datetime' ||
|
|
501
|
+
field.type === 'time' ||
|
|
403
502
|
field.type === 'phone' ||
|
|
503
|
+
field.type === 'website' ||
|
|
504
|
+
field.type === 'choice' ||
|
|
404
505
|
field.type === 'hidden' ||
|
|
405
506
|
field.type === 'select';
|
|
406
507
|
// 1) Prefill prop (takes precedence over URL)
|
|
407
508
|
if (prefill && prefillKey && prefillKey.trim() !== '' && prefillKey in prefill) {
|
|
408
509
|
const v = prefill[prefillKey];
|
|
409
510
|
if (typeof v === 'string' && canPrefillType) {
|
|
410
|
-
|
|
411
|
-
|
|
511
|
+
const options = field.options || [];
|
|
512
|
+
const optionField = field.type === 'select' || field.type === 'choice';
|
|
513
|
+
if (optionField && options.length > 0 && !options.includes(v)) {
|
|
514
|
+
// Ignore invalid option values
|
|
412
515
|
}
|
|
413
516
|
else {
|
|
414
517
|
initialValue = v;
|
|
@@ -420,8 +523,10 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
420
523
|
const v = urlParams.get(prefillKey);
|
|
421
524
|
if (v !== null && v !== '') {
|
|
422
525
|
if (canPrefillType) {
|
|
423
|
-
|
|
424
|
-
|
|
526
|
+
const options = field.options || [];
|
|
527
|
+
const optionField = field.type === 'select' || field.type === 'choice';
|
|
528
|
+
if (optionField && options.length > 0 && !options.includes(v)) {
|
|
529
|
+
// Ignore invalid option values
|
|
425
530
|
}
|
|
426
531
|
else {
|
|
427
532
|
initialValue = v;
|
|
@@ -429,16 +534,6 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
429
534
|
}
|
|
430
535
|
}
|
|
431
536
|
}
|
|
432
|
-
// Debugging: log resolved prefill for hidden fields
|
|
433
|
-
if (typeof window !== 'undefined' && field.type === 'hidden') {
|
|
434
|
-
// eslint-disable-next-line no-console
|
|
435
|
-
console.debug('FormSnip hidden prefill', {
|
|
436
|
-
fieldId: field.id,
|
|
437
|
-
prefillKey,
|
|
438
|
-
resolvedValue: initialValue,
|
|
439
|
-
hasDefault: !!field.value,
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
537
|
// Hidden fields can also have a static default value from the schema
|
|
443
538
|
if (initialValue === '' && field.type === 'hidden' && field.value) {
|
|
444
539
|
initialValue = field.value;
|
|
@@ -484,7 +579,7 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
484
579
|
delete newErrors[fieldId];
|
|
485
580
|
return newErrors;
|
|
486
581
|
});
|
|
487
|
-
const result = await uploadFiles(files, formId, baseUrl);
|
|
582
|
+
const result = await uploadFiles(files, formId, baseUrl, submitToken);
|
|
488
583
|
setUploadingFiles((prev) => ({ ...prev, [fieldId]: false }));
|
|
489
584
|
if (result.success && result.fileKeys && result.fileKeys.length > 0) {
|
|
490
585
|
const existingFileKeys = Array.isArray(formData[fieldId])
|
|
@@ -508,6 +603,16 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
508
603
|
setSubmitSuccess(false);
|
|
509
604
|
setIsSubmitting(true);
|
|
510
605
|
try {
|
|
606
|
+
const pageUrl = new URL(window.location.href);
|
|
607
|
+
const payload = {
|
|
608
|
+
...formData,
|
|
609
|
+
[SUBMIT_TOKEN_FIELD]: submitToken,
|
|
610
|
+
[HONEYPOT_FIELD]: honeypot,
|
|
611
|
+
_fs_page: pageUrl.href,
|
|
612
|
+
_fs_utm_source: pageUrl.searchParams.get('utm_source') || undefined,
|
|
613
|
+
_fs_utm_medium: pageUrl.searchParams.get('utm_medium') || undefined,
|
|
614
|
+
_fs_utm_campaign: pageUrl.searchParams.get('utm_campaign') || undefined,
|
|
615
|
+
};
|
|
511
616
|
const response = await fetch(`${baseUrl}/submissions/create-submission/${formId}`, {
|
|
512
617
|
method: 'POST',
|
|
513
618
|
headers: {
|
|
@@ -515,21 +620,23 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
515
620
|
'X-Requested-With': 'XMLHttpRequest',
|
|
516
621
|
},
|
|
517
622
|
credentials: 'include',
|
|
518
|
-
body: JSON.stringify(
|
|
623
|
+
body: JSON.stringify(payload),
|
|
519
624
|
});
|
|
520
625
|
const json = await response.json();
|
|
521
626
|
if (response.ok && (json.success === undefined || json.success === true)) {
|
|
522
627
|
setSubmitSuccess(true);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
formName: form?.name,
|
|
528
|
-
});
|
|
529
|
-
}
|
|
628
|
+
pushFormSnipSubmissionDataLayer({
|
|
629
|
+
formId,
|
|
630
|
+
formName: form?.name,
|
|
631
|
+
});
|
|
530
632
|
if (onSuccess) {
|
|
531
633
|
onSuccess(json.data);
|
|
532
634
|
}
|
|
635
|
+
const redirectUrl = form?.validation?.redirectUrl?.trim();
|
|
636
|
+
if (redirectUrl && /^https?:\/\//i.test(redirectUrl)) {
|
|
637
|
+
window.location.assign(redirectUrl);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
533
640
|
// Reset form
|
|
534
641
|
const emptyData = Object.keys(formData).reduce((acc, key) => {
|
|
535
642
|
const field = form?.fields.find((f) => f.id === key);
|
|
@@ -539,6 +646,15 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
539
646
|
setFormData(emptyData);
|
|
540
647
|
setSelectedFiles({});
|
|
541
648
|
setUploadErrors({});
|
|
649
|
+
setHoneypot('');
|
|
650
|
+
try {
|
|
651
|
+
const refresh = await fetch(`${baseUrl}/forms/get-form/${formId}`);
|
|
652
|
+
const refreshJson = await refresh.json();
|
|
653
|
+
if (refreshJson?.data?.submitToken) {
|
|
654
|
+
setSubmitToken(refreshJson.data.submitToken);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
catch (_) { }
|
|
542
658
|
}
|
|
543
659
|
else {
|
|
544
660
|
const errorMsg = json.message || 'Submission failed';
|
|
@@ -563,6 +679,7 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
563
679
|
// Use embedStyles from database (set in EmbedForm page)
|
|
564
680
|
const fieldStyles = form?.embedStyles?.field || {};
|
|
565
681
|
const isRequired = field.required === true;
|
|
682
|
+
const inputStyle = applyStyles({}, fieldStyles);
|
|
566
683
|
const commonProps = {
|
|
567
684
|
id: `field-${field.id}`,
|
|
568
685
|
name: field.id,
|
|
@@ -570,8 +687,8 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
570
687
|
onChange: (e) => handleInputChange(field.id, e.target.value),
|
|
571
688
|
placeholder: field.placeholder || '',
|
|
572
689
|
required: isRequired,
|
|
573
|
-
style:
|
|
574
|
-
className: field.type === 'textarea' ? CLASSES.textarea : CLASSES.input
|
|
690
|
+
style: inputStyle,
|
|
691
|
+
className: `${field.type === 'textarea' ? CLASSES.textarea : CLASSES.input} formsnip-embed-ph`,
|
|
575
692
|
};
|
|
576
693
|
const labelStyle = {};
|
|
577
694
|
if (fieldStyles.labelFontSize) {
|
|
@@ -580,18 +697,54 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
580
697
|
if (field.type === 'hidden') {
|
|
581
698
|
return (React.createElement("input", { type: "hidden", id: `field-${field.id}`, name: field.id, value: formData[field.id] || field.value || '' }));
|
|
582
699
|
}
|
|
700
|
+
if (field.type === 'heading') {
|
|
701
|
+
return React.createElement("h2", { style: { margin: 0, fontSize: '1.5rem', fontWeight: 600 } }, field.label);
|
|
702
|
+
}
|
|
703
|
+
if (field.type === 'paragraph') {
|
|
704
|
+
return React.createElement("p", { style: { margin: 0, whiteSpace: 'pre-wrap', color: '#4b5563' } }, field.content || '');
|
|
705
|
+
}
|
|
706
|
+
if (field.type === 'divider') {
|
|
707
|
+
return React.createElement("hr", { style: { border: 0, borderTop: '1px solid #e5e7eb', margin: '0.25rem 0' } });
|
|
708
|
+
}
|
|
709
|
+
if (field.type === 'choice') {
|
|
710
|
+
const options = field.options && field.options.length > 0 ? field.options : ['Yes', 'No'];
|
|
711
|
+
return (React.createElement("div", { className: "fs-relative" },
|
|
712
|
+
!field.hideLabel && (React.createElement("label", { className: "fs-mb-2 fs-flex fs-w-full fs-items-center fs-justify-between fs-gap-2 fs-text-sm fs-text-gray-700", style: { fontWeight: 500, ...labelStyle } },
|
|
713
|
+
React.createElement("span", { className: "fs-min-w-0 fs-flex-1" }, field.label),
|
|
714
|
+
isRequired && React.createElement("span", { className: "fs-text-red-500 fs-flex-shrink-0", style: { lineHeight: 1 } }, "*"))),
|
|
715
|
+
options.map((option) => (React.createElement("label", { key: option, className: "fs-flex fs-items-center", style: { marginTop: '0.4rem' } },
|
|
716
|
+
React.createElement("input", { type: "radio", name: field.id, value: option, checked: formData[field.id] === option, onChange: (event) => handleInputChange(field.id, event.target.value), required: isRequired }),
|
|
717
|
+
React.createElement("span", { style: { marginLeft: '0.5rem' } }, option))))));
|
|
718
|
+
}
|
|
719
|
+
if (field.type === 'phone') {
|
|
720
|
+
const parsed = parsePhoneValue(formData[field.id] || '', defaultPhoneIso());
|
|
721
|
+
const country = phoneCountryByIso(parsed.iso);
|
|
722
|
+
return (React.createElement(React.Fragment, null,
|
|
723
|
+
!field.hideLabel && (React.createElement("label", { htmlFor: `field-${field.id}`, className: "fs-mb-2 fs-flex fs-w-full fs-items-center fs-justify-between fs-gap-2 fs-text-sm fs-text-gray-700", style: { fontWeight: 500, ...labelStyle } },
|
|
724
|
+
React.createElement("span", { className: "fs-min-w-0 fs-flex-1" }, field.label),
|
|
725
|
+
isRequired && React.createElement("span", { className: "fs-text-red-500 fs-flex-shrink-0", style: { lineHeight: 1 } }, "*"))),
|
|
726
|
+
React.createElement("div", { style: { display: 'flex', gap: '0.5rem' } },
|
|
727
|
+
React.createElement("select", { "aria-label": "Country code", value: country.iso, onChange: (event) => {
|
|
728
|
+
const next = phoneCountryByIso(event.target.value);
|
|
729
|
+
handleInputChange(field.id, formatPhoneValue(next.dial, parsed.national));
|
|
730
|
+
}, className: CLASSES.input, style: { ...inputStyle, width: '8.5rem', flex: '0 0 auto' } }, PHONE_COUNTRIES.map((item) => (React.createElement("option", { key: item.iso, value: item.iso },
|
|
731
|
+
item.dial,
|
|
732
|
+
" ",
|
|
733
|
+
item.name)))),
|
|
734
|
+
React.createElement("input", { id: `field-${field.id}`, name: field.id, type: "tel", value: parsed.national, onChange: (event) => handleInputChange(field.id, formatPhoneValue(country.dial, event.target.value)), placeholder: field.placeholder || '12 34 56 78', required: isRequired, style: { ...inputStyle, flex: 1 }, className: `${CLASSES.input} formsnip-embed-ph` }))));
|
|
735
|
+
}
|
|
583
736
|
// For checkbox and radio, return without extra wrapper
|
|
584
737
|
if (field.type === 'checkbox') {
|
|
585
|
-
return (React.createElement("label", { className: "fs-flex fs-items-center" },
|
|
738
|
+
return (React.createElement("label", { className: "fs-flex fs-w-full fs-items-center fs-gap-2" },
|
|
586
739
|
React.createElement("input", { type: "checkbox", id: `field-${field.id}`, name: field.id, checked: String(formData[field.id]) === 'true' || formData[field.id] === 'on', onChange: (e) => handleInputChange(field.id, e.target.checked ? 'true' : 'false'), required: isRequired }),
|
|
587
|
-
React.createElement("span", { style: { marginLeft: '0.5rem' } }, field.label || field.placeholder || 'Check this box'),
|
|
588
|
-
isRequired && React.createElement("span", { className: "fs-text-red-500", style: {
|
|
740
|
+
!field.hideLabel && (React.createElement("span", { className: "fs-min-w-0 fs-flex-1", style: { marginLeft: '0.5rem' } }, field.label || field.placeholder || 'Check this box')),
|
|
741
|
+
isRequired && React.createElement("span", { className: "fs-text-red-500 fs-flex-shrink-0 fs-ml-auto", style: { lineHeight: 1 } }, "*")));
|
|
589
742
|
}
|
|
590
743
|
if (field.type === 'radio') {
|
|
591
744
|
return (React.createElement("div", { className: "fs-relative" },
|
|
592
|
-
React.createElement("label", { className: "fs-text-sm fs-text-gray-700
|
|
593
|
-
field.label,
|
|
594
|
-
isRequired && React.createElement("span", { className: "fs-text-red-500", style: {
|
|
745
|
+
!field.hideLabel && (React.createElement("label", { className: "fs-mb-2 fs-flex fs-w-full fs-items-center fs-justify-between fs-gap-2 fs-text-sm fs-text-gray-700", style: { fontWeight: 500 } },
|
|
746
|
+
React.createElement("span", { className: "fs-min-w-0 fs-flex-1" }, field.label),
|
|
747
|
+
isRequired && React.createElement("span", { className: "fs-text-red-500 fs-flex-shrink-0", style: { lineHeight: 1 } }, "*"))),
|
|
595
748
|
React.createElement("label", { className: "fs-flex fs-items-center" },
|
|
596
749
|
React.createElement("input", { type: "radio", id: `field-${field.id}-yes`, name: field.id, value: "yes", checked: formData[field.id] === 'yes', onChange: (e) => handleInputChange(field.id, e.target.value), required: isRequired }),
|
|
597
750
|
React.createElement("span", { style: { marginLeft: '0.5rem' } }, " Yes")),
|
|
@@ -606,19 +759,19 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
606
759
|
? [formData[field.id]]
|
|
607
760
|
: [];
|
|
608
761
|
return (React.createElement(React.Fragment, null,
|
|
609
|
-
React.createElement("label", { htmlFor: `field-${field.id}`, className: "fs-text-sm fs-text-gray-700
|
|
610
|
-
field.label,
|
|
611
|
-
isRequired && React.createElement("span", { className: "fs-text-red-500", style: {
|
|
762
|
+
!field.hideLabel && (React.createElement("label", { htmlFor: `field-${field.id}`, className: "fs-mb-2 fs-flex fs-w-full fs-items-center fs-justify-between fs-gap-2 fs-text-sm fs-text-gray-700", style: { fontWeight: 500 } },
|
|
763
|
+
React.createElement("span", { className: "fs-min-w-0 fs-flex-1" }, field.label),
|
|
764
|
+
isRequired && React.createElement("span", { className: "fs-text-red-500 fs-flex-shrink-0", style: { lineHeight: 1 } }, "*"))),
|
|
612
765
|
React.createElement(FileUpload, { id: field.id, required: isRequired, disabled: uploadingFiles[field.id], multiple: true, onFilesChange: (files) => handleFilesChange(field.id, files), uploadedFiles: uploadedFileKeys, uploading: uploadingFiles[field.id], error: uploadErrors[field.id] })));
|
|
613
766
|
}
|
|
614
767
|
// For other fields, use minimal wrapper structure
|
|
615
768
|
return (React.createElement(React.Fragment, null,
|
|
616
|
-
React.createElement("label", { htmlFor: `field-${field.id}`, className: "fs-text-sm fs-text-gray-700
|
|
617
|
-
field.label,
|
|
618
|
-
isRequired && React.createElement("span", { className: "fs-text-red-500", style: {
|
|
619
|
-
React.createElement("div", { className: CLASSES.inner }, field.type === 'select' ? (React.createElement("select", { id: `field-${field.id}`, name: field.id, value: formData[field.id] || '', onChange: (e) => handleInputChange(field.id, e.target.value), required: isRequired, style:
|
|
769
|
+
!field.hideLabel && (React.createElement("label", { htmlFor: `field-${field.id}`, className: "fs-mb-2 fs-flex fs-w-full fs-items-center fs-justify-between fs-gap-2 fs-text-sm fs-text-gray-700", style: { fontWeight: 500, ...labelStyle } },
|
|
770
|
+
React.createElement("span", { className: "fs-min-w-0 fs-flex-1" }, field.label),
|
|
771
|
+
isRequired && React.createElement("span", { className: "fs-text-red-500 fs-flex-shrink-0", style: { lineHeight: 1 } }, "*"))),
|
|
772
|
+
React.createElement("div", { className: CLASSES.inner }, field.type === 'select' ? (React.createElement("select", { id: `field-${field.id}`, name: field.id, value: formData[field.id] || '', onChange: (e) => handleInputChange(field.id, e.target.value), required: isRequired, style: inputStyle, className: `${CLASSES.input} formsnip-embed-ph` },
|
|
620
773
|
React.createElement("option", { value: "" }, field.placeholder || 'Select an option'),
|
|
621
|
-
field.options?.map((option, index) => (React.createElement("option", { key: index, value: option }, option))))) : field.type === 'textarea' ? (React.createElement("textarea", { ...commonProps, rows: 4 })) : field.type === 'phone' ? (React.createElement("input", { type: "tel", ...commonProps })) : field.type === 'datetime' ? (React.createElement("input", { type: "datetime-local", ...commonProps })) : (React.createElement("input", { type: field.type || 'text', ...commonProps })))));
|
|
774
|
+
field.options?.map((option, index) => (React.createElement("option", { key: index, value: option }, option))))) : field.type === 'textarea' ? (React.createElement("textarea", { ...commonProps, rows: 4 })) : field.type === 'phone' ? (React.createElement("input", { type: "tel", ...commonProps })) : field.type === 'website' ? (React.createElement("input", { type: "url", ...commonProps })) : field.type === 'time' ? (React.createElement("input", { type: "time", ...commonProps })) : field.type === 'datetime' ? (React.createElement("input", { type: "datetime-local", ...commonProps })) : (React.createElement("input", { type: field.type || 'text', ...commonProps })))));
|
|
622
775
|
};
|
|
623
776
|
if (loading) {
|
|
624
777
|
const content = (React.createElement("div", { className: "fs-text-center", style: { padding: '2rem 0' } },
|
|
@@ -647,6 +800,14 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
647
800
|
marginBottom: '1rem',
|
|
648
801
|
} }, form.validation?.confirmationText || 'Thank you! Your submission has been received.')),
|
|
649
802
|
React.createElement("form", { id: "formsnip-form", onSubmit: handleSubmit, className: CLASSES.form },
|
|
803
|
+
React.createElement("input", { type: "text", name: HONEYPOT_FIELD, value: honeypot, onChange: (e) => setHoneypot(e.target.value), tabIndex: -1, autoComplete: "off", "aria-hidden": "true", style: {
|
|
804
|
+
position: 'absolute',
|
|
805
|
+
left: '-9999px',
|
|
806
|
+
height: 0,
|
|
807
|
+
width: 0,
|
|
808
|
+
opacity: 0,
|
|
809
|
+
overflow: 'hidden',
|
|
810
|
+
} }),
|
|
650
811
|
form.fields.filter((f) => f.isActive !== false && f.type === 'hidden').map((field) => (React.createElement(React.Fragment, { key: field.id }, renderField(field)))),
|
|
651
812
|
buildRowsFromFields(form.fields.filter((f) => f.isActive !== false && f.type !== 'hidden')).map((row, rowIdx) => row.length === 1 ? (React.createElement("div", { key: row[0].id, style: { display: 'flex', flexDirection: 'column' } }, renderField(row[0]))) : (React.createElement("div", { key: `row-${rowIdx}`, style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem' } }, row.map((field) => (React.createElement("div", { key: field.id, style: { display: 'flex', flexDirection: 'column' } }, renderField(field))))))),
|
|
652
813
|
React.createElement("button", { type: "submit", disabled: isSubmitting, className: CLASSES.button, style: {
|