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