@sonordev/site-kit 2.6.1 → 2.6.3

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,6 +1,6 @@
1
1
  'use client';
2
2
  import '../chunk-4XPGGLVP.mjs';
3
- import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
3
+ import React2, { useRef, useEffect, useCallback, useState, useMemo } from 'react';
4
4
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
5
5
 
6
6
  // src/forms/formsApi.ts
@@ -506,6 +506,42 @@ function getDeviceType() {
506
506
  return "desktop";
507
507
  }
508
508
 
509
+ // src/forms/fetchWithRetry.ts
510
+ function backoffDelay(attempt, response) {
511
+ if (response?.status === 429) {
512
+ const header = response.headers.get("retry-after");
513
+ const seconds = header ? parseInt(header, 10) : NaN;
514
+ if (!Number.isNaN(seconds) && seconds > 0) return Math.min(seconds * 1e3, 1e4);
515
+ }
516
+ return Math.min(8e3, 500 * 2 ** attempt);
517
+ }
518
+ async function fetchWithRetry(url, init, options = {}) {
519
+ const retries = options.retries ?? 2;
520
+ const timeoutMs = options.timeoutMs ?? 15e3;
521
+ let lastError;
522
+ for (let attempt = 0; attempt <= retries; attempt++) {
523
+ const controller = new AbortController();
524
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
525
+ try {
526
+ const response = await fetch(url, { ...init, signal: controller.signal });
527
+ clearTimeout(timer);
528
+ if ((response.status === 429 || response.status >= 500) && attempt < retries) {
529
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay(attempt, response)));
530
+ continue;
531
+ }
532
+ return response;
533
+ } catch (error) {
534
+ clearTimeout(timer);
535
+ lastError = error;
536
+ if (attempt < retries) {
537
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay(attempt)));
538
+ continue;
539
+ }
540
+ }
541
+ }
542
+ throw lastError instanceof Error ? lastError : new Error("Network request failed after multiple attempts. Please try again.");
543
+ }
544
+
509
545
  // src/forms/recaptcha.ts
510
546
  function getSiteKey(explicitSiteKey) {
511
547
  if (typeof window === "undefined") return void 0;
@@ -513,8 +549,9 @@ function getSiteKey(explicitSiteKey) {
513
549
  const win = window;
514
550
  return win.__SITE_KIT_RECAPTCHA_SITE_KEY__ ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
515
551
  }
552
+ var RECAPTCHA_LOAD_TIMEOUT_MS = 1e4;
516
553
  function loadScript(siteKey) {
517
- return new Promise((resolve, reject) => {
554
+ return new Promise((resolve) => {
518
555
  if (typeof window === "undefined") {
519
556
  resolve(null);
520
557
  return;
@@ -524,20 +561,35 @@ function loadScript(siteKey) {
524
561
  resolve(win.grecaptcha);
525
562
  return;
526
563
  }
564
+ let settled = false;
565
+ const finish = (value) => {
566
+ if (settled) return;
567
+ settled = true;
568
+ resolve(value);
569
+ };
570
+ const giveUp = setTimeout(() => finish(null), RECAPTCHA_LOAD_TIMEOUT_MS);
571
+ const waitForReady = () => {
572
+ if (settled) return;
573
+ if (win.grecaptcha?.enterprise) {
574
+ clearTimeout(giveUp);
575
+ finish(win.grecaptcha);
576
+ return;
577
+ }
578
+ setTimeout(waitForReady, 50);
579
+ };
527
580
  const existing = document.querySelector('script[src*="recaptcha/enterprise"]');
528
581
  if (existing) {
529
- const check = () => {
530
- if (win.grecaptcha?.enterprise) resolve(win.grecaptcha);
531
- else setTimeout(check, 50);
532
- };
533
- check();
582
+ waitForReady();
534
583
  return;
535
584
  }
536
585
  const script = document.createElement("script");
537
586
  script.src = `https://www.google.com/recaptcha/enterprise.js?render=${siteKey}`;
538
587
  script.async = true;
539
- script.onload = () => resolve(win.grecaptcha);
540
- script.onerror = () => reject(new Error("reCAPTCHA script failed to load"));
588
+ script.onload = () => waitForReady();
589
+ script.onerror = () => {
590
+ clearTimeout(giveUp);
591
+ finish(null);
592
+ };
541
593
  document.head.appendChild(script);
542
594
  });
543
595
  }
@@ -555,21 +607,83 @@ async function getRecaptchaToken(explicitSiteKey) {
555
607
  }
556
608
  }
557
609
 
558
- // src/forms/defaultSuccessMessage.ts
559
- var DEFAULT_FORM_SUCCESS_MESSAGE = "Thank you for your submission! We will be in touch soon.";
560
-
561
- // src/forms/useForm.ts
610
+ // src/forms/submitForm.ts
611
+ var DEFAULT_HONEYPOT_FIELD = "_sk_hp_url";
612
+ function getApiConfig() {
613
+ const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
614
+ const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
615
+ return { apiUrl, apiKey };
616
+ }
562
617
  function getUTMParams() {
563
618
  if (typeof window === "undefined") return {};
564
619
  const params = new URLSearchParams(window.location.search);
565
- return {
566
- utm_source: params.get("utm_source"),
567
- utm_medium: params.get("utm_medium"),
568
- utm_campaign: params.get("utm_campaign"),
569
- utm_term: params.get("utm_term"),
570
- utm_content: params.get("utm_content")
620
+ const out = {};
621
+ for (const key of ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]) {
622
+ out[key] = params.get(key);
623
+ }
624
+ return out;
625
+ }
626
+ async function submitForm({
627
+ config: config2,
628
+ values,
629
+ honeypotValue,
630
+ formLoadedAt
631
+ }) {
632
+ const { apiUrl, apiKey } = getApiConfig();
633
+ if (!apiKey) {
634
+ throw new Error("API key is required. Set SONOR_API_KEY in your environment.");
635
+ }
636
+ const recaptchaToken = config2.recaptcha_enabled ? await getRecaptchaToken(config2.recaptcha_site_key) : null;
637
+ if (config2.recaptcha_enabled && !recaptchaToken) {
638
+ throw new Error("Verification could not be completed. Please try again.");
639
+ }
640
+ const honeypotFieldName = config2.honeypot_field || DEFAULT_HONEYPOT_FIELD;
641
+ const utm = getUTMParams();
642
+ const payload = {
643
+ formId: config2.id,
644
+ form_id: config2.id,
645
+ data: {
646
+ ...values,
647
+ ...config2.honeypot_enabled ? { [honeypotFieldName]: honeypotValue } : {}
648
+ },
649
+ metadata: {
650
+ pageUrl: typeof window !== "undefined" ? window.location.href : null,
651
+ referrer: typeof document !== "undefined" ? document.referrer || null : null,
652
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null,
653
+ sessionId: typeof sessionStorage !== "undefined" ? sessionStorage.getItem("_sk_sid") : null,
654
+ recaptchaToken: recaptchaToken || void 0,
655
+ utmSource: utm.utm_source,
656
+ utmMedium: utm.utm_medium,
657
+ utmCampaign: utm.utm_campaign,
658
+ _formLoadedAt: formLoadedAt
659
+ }
571
660
  };
661
+ const response = await fetchWithRetry(
662
+ `${apiUrl}/api/public/forms/submit`,
663
+ {
664
+ method: "POST",
665
+ headers: {
666
+ "Content-Type": "application/json",
667
+ Authorization: `Bearer ${apiKey}`
668
+ },
669
+ body: JSON.stringify(payload)
670
+ },
671
+ { retries: 2, timeoutMs: 2e4 }
672
+ );
673
+ const data = await response.json().catch(() => null);
674
+ if (!response.ok) {
675
+ const msg = (data?.message ?? data?.error ?? (response.statusText || `HTTP ${response.status}`)).toString().trim();
676
+ throw new Error(
677
+ msg ? `Failed to submit form: ${msg}` : `Failed to submit form (${response.status})`
678
+ );
679
+ }
680
+ return data;
572
681
  }
682
+
683
+ // src/forms/defaultSuccessMessage.ts
684
+ var DEFAULT_FORM_SUCCESS_MESSAGE = "Thank you for your submission! We will be in touch soon.";
685
+
686
+ // src/forms/useForm.ts
573
687
  function useForm(formIdOrSlug, options = {}) {
574
688
  const {
575
689
  projectId: optionsProjectId,
@@ -587,6 +701,9 @@ function useForm(formIdOrSlug, options = {}) {
587
701
  const [step, setStep] = useState(1);
588
702
  const [isSubmitting, setIsSubmitting] = useState(false);
589
703
  const [isComplete, setIsComplete] = useState(false);
704
+ const [submitError, setSubmitError] = useState(null);
705
+ const isSubmittingRef = useRef(false);
706
+ const formLoadedAt = useRef(Date.now());
590
707
  const totalSteps = form?.total_steps || 1;
591
708
  const isMultiStep = form?.is_multi_step || false;
592
709
  const { trackStepChange, trackComplete } = useFormTracking({
@@ -595,16 +712,19 @@ function useForm(formIdOrSlug, options = {}) {
595
712
  });
596
713
  useEffect(() => {
597
714
  const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
598
- const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
599
- if (!apiKey) {
600
- setFetchError(new Error("API key is required. Provide it via SiteKitProvider (apiKey) or set __SITE_KIT_API_KEY__."));
601
- setIsLoading(false);
602
- return;
603
- }
604
715
  async function fetchForm() {
605
716
  try {
606
717
  setIsLoading(true);
607
718
  setFetchError(null);
719
+ const readApiKey = () => typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
720
+ let apiKey = readApiKey();
721
+ for (let i = 0; i < 15 && !apiKey; i++) {
722
+ await new Promise((r) => setTimeout(r, 100));
723
+ apiKey = readApiKey();
724
+ }
725
+ if (!apiKey) {
726
+ throw new Error("API key is required. Provide it via SiteKitLayout/SiteKitProvider or set SONOR_API_KEY.");
727
+ }
608
728
  const url = `${apiUrl}/api/public/forms/config`;
609
729
  const init = {
610
730
  method: "POST",
@@ -616,15 +736,7 @@ function useForm(formIdOrSlug, options = {}) {
616
736
  formIdOrSlug
617
737
  })
618
738
  };
619
- const maxAttempts = 4;
620
- let response = await fetch(url, init);
621
- for (let attempt = 1; attempt < maxAttempts && response.status === 429; attempt++) {
622
- const ra = response.headers.get("retry-after");
623
- const retryAfterSec = ra ? parseInt(ra, 10) : NaN;
624
- const delayMs = !Number.isNaN(retryAfterSec) && retryAfterSec > 0 ? retryAfterSec * 1e3 : Math.min(8e3, 400 * 2 ** (attempt - 1));
625
- await new Promise((r) => setTimeout(r, delayMs));
626
- response = await fetch(url, init);
627
- }
739
+ const response = await fetchWithRetry(url, init, { retries: 3, timeoutMs: 15e3 });
628
740
  if (!response.ok) {
629
741
  throw new Error(`Failed to fetch form: ${response.statusText}`);
630
742
  }
@@ -702,6 +814,7 @@ function useForm(formIdOrSlug, options = {}) {
702
814
  }, [fields, isFieldVisible]);
703
815
  const validateField = useCallback((field2) => {
704
816
  if (!isFieldVisible(field2)) return null;
817
+ if (["heading", "section_header", "paragraph", "hidden"].includes(field2.field_type)) return null;
705
818
  const value = values[field2.slug];
706
819
  const rules = field2.validation || {};
707
820
  if (field2.is_required && (!value || value === "")) {
@@ -721,8 +834,13 @@ function useForm(formIdOrSlug, options = {}) {
721
834
  if (rules.max !== void 0 && Number(value) > rules.max) {
722
835
  return `${field2.label} must be no more than ${rules.max}`;
723
836
  }
724
- if (rules.pattern && !new RegExp(rules.pattern).test(strValue)) {
725
- return rules.custom_error || `${field2.label} is invalid`;
837
+ if (rules.pattern) {
838
+ try {
839
+ if (!new RegExp(rules.pattern).test(strValue)) {
840
+ return rules.custom_error || `${field2.label} is invalid`;
841
+ }
842
+ } catch {
843
+ }
726
844
  }
727
845
  if (field2.field_type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(strValue)) {
728
846
  return "Please enter a valid email address";
@@ -743,6 +861,23 @@ function useForm(formIdOrSlug, options = {}) {
743
861
  setErrors(newErrors);
744
862
  return Object.keys(newErrors).length === 0;
745
863
  }, [fields, validateField]);
864
+ const validateAll = useCallback(() => {
865
+ const newErrors = {};
866
+ for (const field2 of allFields) {
867
+ const error = validateField(field2);
868
+ if (error) newErrors[field2.slug] = error;
869
+ }
870
+ setErrors(newErrors);
871
+ return Object.keys(newErrors).length === 0;
872
+ }, [allFields, validateField]);
873
+ const buildVisibleValues = useCallback(() => {
874
+ const out = {};
875
+ for (const field2 of allFields) {
876
+ if (!isFieldVisible(field2)) continue;
877
+ if (field2.slug in values) out[field2.slug] = values[field2.slug];
878
+ }
879
+ return out;
880
+ }, [allFields, values, isFieldVisible]);
746
881
  const setFieldValue = useCallback((key, value) => {
747
882
  setValuesState((prev) => ({ ...prev, [key]: value }));
748
883
  setErrors((prev) => {
@@ -789,49 +924,18 @@ function useForm(formIdOrSlug, options = {}) {
789
924
  }, [totalSteps, trackStepChange]);
790
925
  const submit = useCallback(async () => {
791
926
  if (!form) return;
792
- if (!validate()) return;
927
+ if (isSubmittingRef.current) return;
928
+ if (!validateAll()) return;
929
+ isSubmittingRef.current = true;
793
930
  setIsSubmitting(true);
931
+ setSubmitError(null);
794
932
  try {
795
- const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
796
- const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
797
- if (!apiKey) {
798
- throw new Error("API key is required. Set SONOR_API_KEY in your .env");
799
- }
800
- const honeypotFieldName = form.honeypot_field || "website";
801
- const recaptchaToken = form.recaptcha_enabled ? await getRecaptchaToken(form.recaptcha_site_key) : null;
802
- if (form.recaptcha_enabled && !recaptchaToken) {
803
- throw new Error("reCAPTCHA is required but could not be initialized.");
804
- }
805
- const utm = getUTMParams();
806
- const submission = {
807
- formId: form.id,
808
- data: {
809
- ...values,
810
- ...form.honeypot_enabled ? { [honeypotFieldName]: "" } : {}
811
- },
812
- metadata: {
813
- pageUrl: typeof window !== "undefined" ? window.location.href : null,
814
- referrer: typeof document !== "undefined" ? document.referrer || null : null,
815
- userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null,
816
- sessionId: typeof sessionStorage !== "undefined" ? sessionStorage.getItem("_sk_sid") : null,
817
- recaptchaToken: recaptchaToken || void 0,
818
- utmSource: utm.utm_source,
819
- utmMedium: utm.utm_medium,
820
- utmCampaign: utm.utm_campaign
821
- }
822
- };
823
- const response = await fetch(`${apiUrl}/api/public/forms/submit`, {
824
- method: "POST",
825
- headers: {
826
- "Content-Type": "application/json",
827
- "Authorization": `Bearer ${apiKey}`
828
- },
829
- body: JSON.stringify(submission)
933
+ const data = await submitForm({
934
+ config: form,
935
+ values: buildVisibleValues(),
936
+ honeypotValue: "",
937
+ formLoadedAt: formLoadedAt.current
830
938
  });
831
- if (!response.ok) {
832
- throw new Error(`Form submission failed: ${response.statusText}`);
833
- }
834
- const data = await response.json();
835
939
  trackComplete();
836
940
  setIsComplete(true);
837
941
  onSuccess?.(data);
@@ -841,21 +945,25 @@ function useForm(formIdOrSlug, options = {}) {
841
945
  }
842
946
  } catch (error) {
843
947
  console.error("[useForm] Submission error:", error);
948
+ setSubmitError("Something went wrong sending your submission. Please try again.");
844
949
  onError?.(error);
845
950
  } finally {
951
+ isSubmittingRef.current = false;
846
952
  setIsSubmitting(false);
847
953
  }
848
- }, [form, values, validate, trackComplete, onSuccess, onError, redirectUrl]);
954
+ }, [form, validateAll, buildVisibleValues, trackComplete, onSuccess, onError, redirectUrl]);
849
955
  const reset = useCallback(() => {
850
956
  setValuesState(initialValues);
851
957
  setErrors({});
852
958
  setStep(1);
853
959
  setIsComplete(false);
960
+ setSubmitError(null);
854
961
  }, [initialValues]);
855
962
  return {
856
963
  form,
857
964
  isLoading,
858
965
  fetchError,
966
+ submitError,
859
967
  allFields,
860
968
  fields,
861
969
  visibleFields,
@@ -921,7 +1029,7 @@ function DatePicker({
921
1029
  }) {
922
1030
  const today = /* @__PURE__ */ new Date();
923
1031
  const todayStr = toDateStr(today.getFullYear(), today.getMonth(), today.getDate());
924
- const effectiveMin = minDate || todayStr;
1032
+ const effectiveMin = minDate || "";
925
1033
  const parsed = parseDate(value);
926
1034
  const [viewYear, setViewYear] = useState(parsed?.year || today.getFullYear());
927
1035
  const [viewMonth, setViewMonth] = useState(parsed?.month ?? today.getMonth());
@@ -1347,20 +1455,13 @@ function FormField({ field: field2, value, error, onChange, classPrefix = "sk-fo
1347
1455
  }
1348
1456
  ),
1349
1457
  field2.field_type === "file" && /* @__PURE__ */ jsx(
1350
- "input",
1458
+ FileUploadField,
1351
1459
  {
1352
- id: inputId,
1353
- className: `${classPrefix}__input ${classPrefix}__input--file`,
1354
- type: "file",
1355
- name: field2.slug,
1356
- required: field2.is_required,
1357
- onChange: (e) => {
1358
- const file = e.target.files?.[0];
1359
- if (file) {
1360
- onChange(file.name);
1361
- }
1362
- },
1363
- style: baseInputStyle
1460
+ field: field2,
1461
+ inputId,
1462
+ classPrefix,
1463
+ baseInputStyle,
1464
+ onChange
1364
1465
  }
1365
1466
  ),
1366
1467
  field2.field_type === "rating" && /* @__PURE__ */ jsx("div", { className: `${classPrefix}__rating`, style: { display: "flex", gap: 4 }, children: [1, 2, 3, 4, 5].map((star) => /* @__PURE__ */ jsx(
@@ -1400,6 +1501,79 @@ function FormField({ field: field2, value, error, onChange, classPrefix = "sk-fo
1400
1501
  field2.help_text && !error && field2.field_type !== "checkbox" && /* @__PURE__ */ jsx("p", { className: `${classPrefix}__help`, style: helpStyle, children: field2.help_text })
1401
1502
  ] });
1402
1503
  }
1504
+ function FileUploadField({
1505
+ field: field2,
1506
+ inputId,
1507
+ classPrefix,
1508
+ baseInputStyle,
1509
+ onChange
1510
+ }) {
1511
+ const [status, setStatus] = React2.useState("idle");
1512
+ const [fileName, setFileName] = React2.useState("");
1513
+ const handleFile = async (e) => {
1514
+ const file = e.target.files?.[0];
1515
+ if (!file) return;
1516
+ setFileName(file.name);
1517
+ setStatus("uploading");
1518
+ onChange("");
1519
+ try {
1520
+ const url = await uploadFormFile(file);
1521
+ onChange(url);
1522
+ setStatus("idle");
1523
+ } catch (err) {
1524
+ console.error("[Forms] File upload failed:", err);
1525
+ onChange("");
1526
+ setStatus("error");
1527
+ }
1528
+ };
1529
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1530
+ /* @__PURE__ */ jsx(
1531
+ "input",
1532
+ {
1533
+ id: inputId,
1534
+ className: `${classPrefix}__input ${classPrefix}__input--file`,
1535
+ type: "file",
1536
+ name: field2.slug,
1537
+ required: field2.is_required,
1538
+ disabled: status === "uploading",
1539
+ onChange: handleFile,
1540
+ style: baseInputStyle
1541
+ }
1542
+ ),
1543
+ status === "uploading" && /* @__PURE__ */ jsxs("p", { style: { color: "var(--sk-text-tertiary, #6b7280)", fontSize: 14, marginTop: 4 }, children: [
1544
+ "Uploading ",
1545
+ fileName,
1546
+ "\u2026"
1547
+ ] }),
1548
+ status === "error" && /* @__PURE__ */ jsx("p", { role: "alert", style: { color: "var(--sk-error, #ef4444)", fontSize: 14, marginTop: 4 }, children: "Upload failed. Please choose the file again." })
1549
+ ] });
1550
+ }
1551
+ async function uploadFormFile(file) {
1552
+ const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
1553
+ const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
1554
+ if (!apiKey) {
1555
+ throw new Error("API key is required to upload files.");
1556
+ }
1557
+ const body = new FormData();
1558
+ body.append("file", file);
1559
+ const response = await fetchWithRetry(
1560
+ `${apiUrl}/api/public/forms/upload`,
1561
+ {
1562
+ method: "POST",
1563
+ headers: { Authorization: `Bearer ${apiKey}` },
1564
+ body
1565
+ },
1566
+ { retries: 1, timeoutMs: 6e4 }
1567
+ );
1568
+ if (!response.ok) {
1569
+ throw new Error(`Upload failed (${response.status})`);
1570
+ }
1571
+ const data = await response.json().catch(() => null);
1572
+ if (!data?.url) {
1573
+ throw new Error("Upload did not return a URL.");
1574
+ }
1575
+ return data.url;
1576
+ }
1403
1577
  function FormClient({
1404
1578
  config: config2,
1405
1579
  className,
@@ -1412,9 +1586,11 @@ function FormClient({
1412
1586
  const [step, setStep] = useState(1);
1413
1587
  const [isSubmitting, setIsSubmitting] = useState(false);
1414
1588
  const [isComplete, setIsComplete] = useState(false);
1589
+ const [submitError, setSubmitError] = useState(null);
1415
1590
  const [honeypotValue, setHoneypotValue] = useState("");
1416
- const honeypotFieldName = config2.honeypot_field || "website";
1591
+ const honeypotFieldName = config2.honeypot_field || DEFAULT_HONEYPOT_FIELD;
1417
1592
  const formLoadedAt = useRef(Date.now());
1593
+ const isSubmittingRef = useRef(false);
1418
1594
  const { trackStepChange, trackComplete } = useFormTracking({
1419
1595
  formId: config2.id,
1420
1596
  totalSteps: config2.total_steps
@@ -1474,8 +1650,13 @@ function FormClient({
1474
1650
  if (rules.max !== void 0 && Number(value) > rules.max) {
1475
1651
  return `${field2.label} must be no more than ${rules.max}`;
1476
1652
  }
1477
- if (rules.pattern && !new RegExp(rules.pattern).test(strValue)) {
1478
- return rules.custom_error || `${field2.label} is invalid`;
1653
+ if (rules.pattern) {
1654
+ try {
1655
+ if (!new RegExp(rules.pattern).test(strValue)) {
1656
+ return rules.custom_error || `${field2.label} is invalid`;
1657
+ }
1658
+ } catch {
1659
+ }
1479
1660
  }
1480
1661
  if (field2.field_type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(strValue)) {
1481
1662
  return "Please enter a valid email address";
@@ -1496,6 +1677,35 @@ function FormClient({
1496
1677
  setErrors(newErrors);
1497
1678
  return Object.keys(newErrors).length === 0;
1498
1679
  }, [currentFields, validateField]);
1680
+ const validateAll = useCallback(() => {
1681
+ const allFields = config2.fields || [];
1682
+ const newErrors = {};
1683
+ for (const field2 of allFields) {
1684
+ const error = validateField(field2);
1685
+ if (error) newErrors[field2.slug] = error;
1686
+ }
1687
+ setErrors(newErrors);
1688
+ if (Object.keys(newErrors).length === 0) return { ok: true, firstErrorStep: null };
1689
+ let firstErrorStep = null;
1690
+ if (config2.is_multi_step && config2.steps) {
1691
+ const ordered = [...config2.steps].sort((a, b) => a.step_number - b.step_number);
1692
+ for (const s of ordered) {
1693
+ if (allFields.some((f) => f.step_id === s.id && newErrors[f.slug])) {
1694
+ firstErrorStep = s.step_number;
1695
+ break;
1696
+ }
1697
+ }
1698
+ }
1699
+ return { ok: false, firstErrorStep };
1700
+ }, [config2, validateField]);
1701
+ const buildVisibleValues = useCallback(() => {
1702
+ const out = {};
1703
+ for (const field2 of config2.fields || []) {
1704
+ if (!isFieldVisible(field2)) continue;
1705
+ if (field2.slug in values) out[field2.slug] = values[field2.slug];
1706
+ }
1707
+ return out;
1708
+ }, [config2, values, isFieldVisible]);
1499
1709
  const setFieldValue = useCallback((key, value) => {
1500
1710
  setValues((prev) => ({ ...prev, [key]: value }));
1501
1711
  if (errors[key]) {
@@ -1530,53 +1740,22 @@ function FormClient({
1530
1740
  }
1531
1741
  }, [config2.total_steps, trackStepChange]);
1532
1742
  const submit = useCallback(async () => {
1533
- if (!validateStep()) return;
1743
+ if (isSubmittingRef.current) return;
1744
+ const { ok, firstErrorStep } = validateAll();
1745
+ if (!ok) {
1746
+ if (firstErrorStep != null) setStep(firstErrorStep);
1747
+ return;
1748
+ }
1749
+ isSubmittingRef.current = true;
1534
1750
  setIsSubmitting(true);
1751
+ setSubmitError(null);
1535
1752
  try {
1536
- const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
1537
- const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
1538
- if (!apiKey) {
1539
- throw new Error("API key is required. Set SONOR_API_KEY in your .env");
1540
- }
1541
- const recaptchaToken = config2.recaptcha_enabled ? await getRecaptchaToken(config2.recaptcha_site_key) : null;
1542
- if (config2.recaptcha_enabled && !recaptchaToken) {
1543
- throw new Error("reCAPTCHA is required but could not be initialized.");
1544
- }
1545
- const utm = getUTMParams2();
1546
- const submission = {
1547
- form_id: config2.id,
1548
- project_id: config2.project_id,
1549
- data: {
1550
- ...values,
1551
- ...config2.honeypot_enabled ? { [honeypotFieldName]: honeypotValue } : {}
1552
- },
1553
- routing_type: config2.form_type,
1554
- status: "new",
1555
- metadata: {
1556
- pageUrl: typeof window !== "undefined" ? window.location.href : null,
1557
- referrer: typeof document !== "undefined" ? document.referrer || null : null,
1558
- userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null,
1559
- sessionId: typeof sessionStorage !== "undefined" ? sessionStorage.getItem("_sk_sid") : null,
1560
- recaptchaToken: recaptchaToken || void 0,
1561
- utmSource: utm.utm_source,
1562
- utmMedium: utm.utm_medium,
1563
- utmCampaign: utm.utm_campaign,
1564
- _formLoadedAt: formLoadedAt.current
1565
- }
1566
- };
1567
- const response = await fetch(`${apiUrl}/api/public/forms/submit`, {
1568
- method: "POST",
1569
- headers: {
1570
- "Content-Type": "application/json",
1571
- "Authorization": `Bearer ${apiKey}`
1572
- },
1573
- body: JSON.stringify(submission)
1753
+ const data = await submitForm({
1754
+ config: config2,
1755
+ values: buildVisibleValues(),
1756
+ honeypotValue: config2.honeypot_enabled ? honeypotValue : "",
1757
+ formLoadedAt: formLoadedAt.current
1574
1758
  });
1575
- const data = await response.json().catch(() => null);
1576
- if (!response.ok) {
1577
- const msg = (data?.message ?? data?.error ?? (response.statusText || `HTTP ${response.status}`)).trim();
1578
- throw new Error(msg ? `Failed to submit form: ${msg}` : `Failed to submit form (${response.status})`);
1579
- }
1580
1759
  trackComplete();
1581
1760
  setIsComplete(true);
1582
1761
  onSuccess?.(data);
@@ -1585,11 +1764,13 @@ function FormClient({
1585
1764
  }
1586
1765
  } catch (error) {
1587
1766
  console.error("[Forms] Submission error:", error);
1767
+ setSubmitError("Something went wrong sending your submission. Please try again.");
1588
1768
  onError?.(error);
1589
1769
  } finally {
1770
+ isSubmittingRef.current = false;
1590
1771
  setIsSubmitting(false);
1591
1772
  }
1592
- }, [config2, honeypotFieldName, honeypotValue, values, validateStep, trackComplete, onSuccess, onError]);
1773
+ }, [config2, honeypotValue, validateAll, buildVisibleValues, trackComplete, onSuccess, onError]);
1593
1774
  const progress = useMemo(() => {
1594
1775
  return Math.round(step / config2.total_steps * 100);
1595
1776
  }, [step, config2.total_steps]);
@@ -1687,6 +1868,15 @@ function FormClient({
1687
1868
  step > 1 && /* @__PURE__ */ jsx("button", { type: "button", className: "sk-form__btn sk-form__btn--back", onClick: prevStep, children: "Back" }),
1688
1869
  /* @__PURE__ */ jsx("button", { type: "submit", className: "sk-form__btn sk-form__btn--submit", disabled: isSubmitting, children: isSubmitting ? "Submitting..." : step < config2.total_steps ? "Next" : config2.submit_button_text })
1689
1870
  ] }),
1871
+ submitError && /* @__PURE__ */ jsx(
1872
+ "p",
1873
+ {
1874
+ className: "sk-form__submit-error",
1875
+ role: "alert",
1876
+ style: { color: "var(--sk-error, #ef4444)", fontSize: 14, marginTop: 12 },
1877
+ children: submitError
1878
+ }
1879
+ ),
1690
1880
  config2.honeypot_enabled && /* @__PURE__ */ jsx(
1691
1881
  "input",
1692
1882
  {
@@ -1713,16 +1903,6 @@ function FormClient({
1713
1903
  }
1714
1904
  );
1715
1905
  }
1716
- function getUTMParams2() {
1717
- if (typeof window === "undefined") return {};
1718
- const params = new URLSearchParams(window.location.search);
1719
- const utmParams = {};
1720
- for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
1721
- const value = params.get(key);
1722
- if (value) utmParams[key] = value;
1723
- }
1724
- return utmParams;
1725
- }
1726
1906
  function ManagedForm({
1727
1907
  formId,
1728
1908
  projectId,