@formsnip/embed 1.1.1 → 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/dist/FormSnip.d.ts.map +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.esm.js +200 -27
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +200 -27
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +14 -4
- package/dist/types.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/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,7 +61,10 @@ interface FormSnipProps {
|
|
|
54
61
|
onSuccess?: (data: any) => void;
|
|
55
62
|
onError?: (error: string) => void;
|
|
56
63
|
className?: string;
|
|
57
|
-
/**
|
|
64
|
+
/**
|
|
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`.
|
|
67
|
+
*/
|
|
58
68
|
prefill?: Record<string, string>;
|
|
59
69
|
}
|
|
60
70
|
interface SubmissionResponse {
|
|
@@ -79,7 +89,7 @@ interface FileUploadProps {
|
|
|
79
89
|
}
|
|
80
90
|
declare const FileUpload: React.FC<FileUploadProps>;
|
|
81
91
|
|
|
82
|
-
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";
|
|
83
93
|
declare const MAX_TOTAL_FILE_SIZE: number;
|
|
84
94
|
interface UploadResult {
|
|
85
95
|
success: boolean;
|
|
@@ -94,11 +104,11 @@ interface UploadMultipleResult {
|
|
|
94
104
|
/**
|
|
95
105
|
* Upload a file to R2 via presigned URL
|
|
96
106
|
*/
|
|
97
|
-
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>;
|
|
98
108
|
/**
|
|
99
109
|
* Upload multiple files to R2
|
|
100
110
|
*/
|
|
101
|
-
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>;
|
|
102
112
|
|
|
103
113
|
/**
|
|
104
114
|
* Push a GTM-friendly event to the global dataLayer after a successful submission.
|
|
@@ -110,4 +120,4 @@ declare function pushFormSnipSubmissionDataLayer(options: {
|
|
|
110
120
|
}): void;
|
|
111
121
|
|
|
112
122
|
export { ALLOWED_FILE_EXTENSIONS, FileUpload, FormSnip, MAX_TOTAL_FILE_SIZE, pushFormSnipSubmissionDataLayer, uploadFile, uploadFiles };
|
|
113
|
-
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;
|
|
@@ -333,6 +347,86 @@ function pushFormSnipSubmissionDataLayer(options) {
|
|
|
333
347
|
}
|
|
334
348
|
}
|
|
335
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
|
+
|
|
336
430
|
/** Build rows from flat fields (full = own row, half = paired in row) */
|
|
337
431
|
function buildRowsFromFields(fields) {
|
|
338
432
|
const rows = [];
|
|
@@ -368,6 +462,8 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
368
462
|
const [submitError, setSubmitError] = useState(null);
|
|
369
463
|
const [submitSuccess, setSubmitSuccess] = useState(false);
|
|
370
464
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
465
|
+
const [submitToken, setSubmitToken] = useState('');
|
|
466
|
+
const [honeypot, setHoneypot] = useState('');
|
|
371
467
|
useEffect(() => {
|
|
372
468
|
// Inject base styles on mount (same as embed script)
|
|
373
469
|
injectBaseStyles();
|
|
@@ -382,6 +478,7 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
382
478
|
// Form data includes embedStyles from the database
|
|
383
479
|
// These styles are set in the EmbedForm page and stored in the Form model
|
|
384
480
|
setForm(json.data);
|
|
481
|
+
setSubmitToken(json.data.submitToken || '');
|
|
385
482
|
// Parse URL query once for prefill support
|
|
386
483
|
let urlParams = null;
|
|
387
484
|
if (typeof window !== 'undefined') {
|
|
@@ -394,22 +491,27 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
394
491
|
return acc;
|
|
395
492
|
}
|
|
396
493
|
let initialValue = '';
|
|
397
|
-
const prefillKey = field.
|
|
494
|
+
const prefillKey = field.id;
|
|
398
495
|
const canPrefillType = field.type === 'text' ||
|
|
399
496
|
field.type === 'email' ||
|
|
400
497
|
field.type === 'textarea' ||
|
|
401
498
|
field.type === 'number' ||
|
|
402
499
|
field.type === 'date' ||
|
|
403
500
|
field.type === 'datetime' ||
|
|
501
|
+
field.type === 'time' ||
|
|
404
502
|
field.type === 'phone' ||
|
|
503
|
+
field.type === 'website' ||
|
|
504
|
+
field.type === 'choice' ||
|
|
405
505
|
field.type === 'hidden' ||
|
|
406
506
|
field.type === 'select';
|
|
407
507
|
// 1) Prefill prop (takes precedence over URL)
|
|
408
508
|
if (prefill && prefillKey && prefillKey.trim() !== '' && prefillKey in prefill) {
|
|
409
509
|
const v = prefill[prefillKey];
|
|
410
510
|
if (typeof v === 'string' && canPrefillType) {
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
413
515
|
}
|
|
414
516
|
else {
|
|
415
517
|
initialValue = v;
|
|
@@ -421,8 +523,10 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
421
523
|
const v = urlParams.get(prefillKey);
|
|
422
524
|
if (v !== null && v !== '') {
|
|
423
525
|
if (canPrefillType) {
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
426
530
|
}
|
|
427
531
|
else {
|
|
428
532
|
initialValue = v;
|
|
@@ -475,7 +579,7 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
475
579
|
delete newErrors[fieldId];
|
|
476
580
|
return newErrors;
|
|
477
581
|
});
|
|
478
|
-
const result = await uploadFiles(files, formId, baseUrl);
|
|
582
|
+
const result = await uploadFiles(files, formId, baseUrl, submitToken);
|
|
479
583
|
setUploadingFiles((prev) => ({ ...prev, [fieldId]: false }));
|
|
480
584
|
if (result.success && result.fileKeys && result.fileKeys.length > 0) {
|
|
481
585
|
const existingFileKeys = Array.isArray(formData[fieldId])
|
|
@@ -499,6 +603,16 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
499
603
|
setSubmitSuccess(false);
|
|
500
604
|
setIsSubmitting(true);
|
|
501
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
|
+
};
|
|
502
616
|
const response = await fetch(`${baseUrl}/submissions/create-submission/${formId}`, {
|
|
503
617
|
method: 'POST',
|
|
504
618
|
headers: {
|
|
@@ -506,7 +620,7 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
506
620
|
'X-Requested-With': 'XMLHttpRequest',
|
|
507
621
|
},
|
|
508
622
|
credentials: 'include',
|
|
509
|
-
body: JSON.stringify(
|
|
623
|
+
body: JSON.stringify(payload),
|
|
510
624
|
});
|
|
511
625
|
const json = await response.json();
|
|
512
626
|
if (response.ok && (json.success === undefined || json.success === true)) {
|
|
@@ -518,6 +632,11 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
518
632
|
if (onSuccess) {
|
|
519
633
|
onSuccess(json.data);
|
|
520
634
|
}
|
|
635
|
+
const redirectUrl = form?.validation?.redirectUrl?.trim();
|
|
636
|
+
if (redirectUrl && /^https?:\/\//i.test(redirectUrl)) {
|
|
637
|
+
window.location.assign(redirectUrl);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
521
640
|
// Reset form
|
|
522
641
|
const emptyData = Object.keys(formData).reduce((acc, key) => {
|
|
523
642
|
const field = form?.fields.find((f) => f.id === key);
|
|
@@ -527,6 +646,15 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
527
646
|
setFormData(emptyData);
|
|
528
647
|
setSelectedFiles({});
|
|
529
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 (_) { }
|
|
530
658
|
}
|
|
531
659
|
else {
|
|
532
660
|
const errorMsg = json.message || 'Submission failed';
|
|
@@ -551,6 +679,7 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
551
679
|
// Use embedStyles from database (set in EmbedForm page)
|
|
552
680
|
const fieldStyles = form?.embedStyles?.field || {};
|
|
553
681
|
const isRequired = field.required === true;
|
|
682
|
+
const inputStyle = applyStyles({}, fieldStyles);
|
|
554
683
|
const commonProps = {
|
|
555
684
|
id: `field-${field.id}`,
|
|
556
685
|
name: field.id,
|
|
@@ -558,8 +687,8 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
558
687
|
onChange: (e) => handleInputChange(field.id, e.target.value),
|
|
559
688
|
placeholder: field.placeholder || '',
|
|
560
689
|
required: isRequired,
|
|
561
|
-
style:
|
|
562
|
-
className: field.type === 'textarea' ? CLASSES.textarea : CLASSES.input
|
|
690
|
+
style: inputStyle,
|
|
691
|
+
className: `${field.type === 'textarea' ? CLASSES.textarea : CLASSES.input} formsnip-embed-ph`,
|
|
563
692
|
};
|
|
564
693
|
const labelStyle = {};
|
|
565
694
|
if (fieldStyles.labelFontSize) {
|
|
@@ -568,18 +697,54 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
568
697
|
if (field.type === 'hidden') {
|
|
569
698
|
return (React.createElement("input", { type: "hidden", id: `field-${field.id}`, name: field.id, value: formData[field.id] || field.value || '' }));
|
|
570
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
|
+
}
|
|
571
736
|
// For checkbox and radio, return without extra wrapper
|
|
572
737
|
if (field.type === 'checkbox') {
|
|
573
|
-
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" },
|
|
574
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 }),
|
|
575
|
-
React.createElement("span", { style: { marginLeft: '0.5rem' } }, field.label || field.placeholder || 'Check this box'),
|
|
576
|
-
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 } }, "*")));
|
|
577
742
|
}
|
|
578
743
|
if (field.type === 'radio') {
|
|
579
744
|
return (React.createElement("div", { className: "fs-relative" },
|
|
580
|
-
React.createElement("label", { className: "fs-text-sm fs-text-gray-700
|
|
581
|
-
field.label,
|
|
582
|
-
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 } }, "*"))),
|
|
583
748
|
React.createElement("label", { className: "fs-flex fs-items-center" },
|
|
584
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 }),
|
|
585
750
|
React.createElement("span", { style: { marginLeft: '0.5rem' } }, " Yes")),
|
|
@@ -594,19 +759,19 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
594
759
|
? [formData[field.id]]
|
|
595
760
|
: [];
|
|
596
761
|
return (React.createElement(React.Fragment, null,
|
|
597
|
-
React.createElement("label", { htmlFor: `field-${field.id}`, className: "fs-text-sm fs-text-gray-700
|
|
598
|
-
field.label,
|
|
599
|
-
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 } }, "*"))),
|
|
600
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] })));
|
|
601
766
|
}
|
|
602
767
|
// For other fields, use minimal wrapper structure
|
|
603
768
|
return (React.createElement(React.Fragment, null,
|
|
604
|
-
React.createElement("label", { htmlFor: `field-${field.id}`, className: "fs-text-sm fs-text-gray-700
|
|
605
|
-
field.label,
|
|
606
|
-
isRequired && React.createElement("span", { className: "fs-text-red-500", style: {
|
|
607
|
-
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` },
|
|
608
773
|
React.createElement("option", { value: "" }, field.placeholder || 'Select an option'),
|
|
609
|
-
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 })))));
|
|
610
775
|
};
|
|
611
776
|
if (loading) {
|
|
612
777
|
const content = (React.createElement("div", { className: "fs-text-center", style: { padding: '2rem 0' } },
|
|
@@ -635,6 +800,14 @@ const FormSnip = ({ formId, baseUrl = DEFAULT_BASE_URL, onSuccess, onError, clas
|
|
|
635
800
|
marginBottom: '1rem',
|
|
636
801
|
} }, form.validation?.confirmationText || 'Thank you! Your submission has been received.')),
|
|
637
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
|
+
} }),
|
|
638
811
|
form.fields.filter((f) => f.isActive !== false && f.type === 'hidden').map((field) => (React.createElement(React.Fragment, { key: field.id }, renderField(field)))),
|
|
639
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))))))),
|
|
640
813
|
React.createElement("button", { type: "submit", disabled: isSubmitting, className: CLASSES.button, style: {
|