@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.
@@ -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,CAmd5C,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,7 +61,10 @@ 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=...) */
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,.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";
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 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;
@@ -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.urlPrefillParam;
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
- if (field.type === 'select' && Array.isArray(field.options) && !field.options.includes(v)) {
412
- // 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
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
- if (field.type === 'select' && Array.isArray(field.options) && !field.options.includes(v)) {
425
- // 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
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(formData),
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: applyStyles({}, fieldStyles),
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: { 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 } }, "*")));
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 fs-mb-2", style: { fontWeight: 500 } },
581
- field.label,
582
- 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 } }, "*"))),
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 fs-mb-2", style: { fontWeight: 500 } },
598
- field.label,
599
- 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 } }, "*"))),
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 fs-mb-2", style: { fontWeight: 500, ...labelStyle } },
605
- field.label,
606
- isRequired && React.createElement("span", { className: "fs-text-red-500", style: { marginLeft: '0.25rem' } }, "*")),
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: 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` },
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: {