@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 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
- ### Google Tag Manager (React)
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
 
@@ -1 +1 @@
1
- {"version":3,"file":"FormSnip.d.ts","sourceRoot":"","sources":["../src/FormSnip.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAC;AACnD,OAAO,EAAQ,aAAa,EAAiC,MAAM,SAAS,CAAC;AA+B7E,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC,aAAa,CAme5C,CAAC"}
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
- /** Optional query parameter name used to prefill this field from the URL */
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
- * When true, pushes to `window.dataLayer` only after the server accepts the submission
61
- * (not on HTML5 validation failure or API errors). For GTM, use a Custom Event trigger
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
- pushDataLayerOnSuccess?: boolean;
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,.svg,.pdf,.mp4,.mpeg,.mov,.avi,.webm,.mkv,.mp3,.wav,.ogg,.aac,.flac";
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 window.dataLayer after a successful submission.
113
- * Safe no-op in SSR or if dataLayer throws.
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 ALLOWED_FILE_EXTENSIONS = '.jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.mp4,.mpeg,.mov,.avi,.webm,.mkv,.mp3,.wav,.ogg,.aac,.flac';
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 window.dataLayer after a successful submission.
316
- * Safe no-op in SSR or if dataLayer throws.
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
- const w = window;
323
- w.dataLayer = w.dataLayer || [];
324
- w.dataLayer.push({
325
- event: options.eventName ?? 'formsnip_submission_success',
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, pushDataLayerOnSuccess = false, dataLayerEventName, }) => {
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.urlPrefillParam;
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
- if (field.type === 'select' && Array.isArray(field.options) && !field.options.includes(v)) {
411
- // Ignore invalid option values for selects
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
- if (field.type === 'select' && Array.isArray(field.options) && !field.options.includes(v)) {
424
- // Ignore invalid option values for selects
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(formData),
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
- if (pushDataLayerOnSuccess) {
524
- pushFormSnipSubmissionDataLayer({
525
- eventName: dataLayerEventName,
526
- formId,
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: applyStyles({}, fieldStyles),
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: { marginLeft: '0.25rem' } }, "*")));
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 fs-mb-2", style: { fontWeight: 500 } },
593
- field.label,
594
- isRequired && React.createElement("span", { className: "fs-text-red-500", style: { marginLeft: '0.25rem' } }, "*")),
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 fs-mb-2", style: { fontWeight: 500 } },
610
- field.label,
611
- isRequired && React.createElement("span", { className: "fs-text-red-500", style: { marginLeft: '0.25rem' } }, "*")),
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 fs-mb-2", style: { fontWeight: 500, ...labelStyle } },
617
- field.label,
618
- isRequired && React.createElement("span", { className: "fs-text-red-500", style: { marginLeft: '0.25rem' } }, "*")),
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: applyStyles({}, fieldStyles), className: CLASSES.input },
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: {