@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.
@@ -2,9 +2,13 @@
2
2
  'use strict';
3
3
 
4
4
  require('../chunk-ZSMWDLMK.js');
5
- var react = require('react');
5
+ var React2 = require('react');
6
6
  var jsxRuntime = require('react/jsx-runtime');
7
7
 
8
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
9
+
10
+ var React2__default = /*#__PURE__*/_interopDefault(React2);
11
+
8
12
  // src/forms/formsApi.ts
9
13
  var config = null;
10
14
  function configureFormsApi(options) {
@@ -328,14 +332,14 @@ function useFormTracking({
328
332
  enabled = true,
329
333
  debug = false
330
334
  }) {
331
- const sessionIdRef = react.useRef("");
332
- const startTimeRef = react.useRef(0);
333
- const stepStartTimeRef = react.useRef(0);
334
- const stepTimesRef = react.useRef({});
335
- const currentStepRef = react.useRef(1);
336
- const maxStepRef = react.useRef(1);
337
- const analyticsIdRef = react.useRef(null);
338
- react.useEffect(() => {
335
+ const sessionIdRef = React2.useRef("");
336
+ const startTimeRef = React2.useRef(0);
337
+ const stepStartTimeRef = React2.useRef(0);
338
+ const stepTimesRef = React2.useRef({});
339
+ const currentStepRef = React2.useRef(1);
340
+ const maxStepRef = React2.useRef(1);
341
+ const analyticsIdRef = React2.useRef(null);
342
+ React2.useEffect(() => {
339
343
  if (!enabled) return;
340
344
  if (!formId) {
341
345
  if (debug) console.warn("[Forms] Tracking skipped: formId is empty");
@@ -406,7 +410,7 @@ function useFormTracking({
406
410
  window.removeEventListener("beforeunload", handleBeforeUnload);
407
411
  };
408
412
  }, [formId, enabled, debug]);
409
- const trackStepChange = react.useCallback(async (step) => {
413
+ const trackStepChange = React2.useCallback(async (step) => {
410
414
  if (!enabled || !analyticsIdRef.current) return;
411
415
  const apiUrl = window.__SITE_KIT_API_URL__;
412
416
  const apiKey = window.__SITE_KIT_API_KEY__;
@@ -442,7 +446,7 @@ function useFormTracking({
442
446
  if (debug) console.error("[Forms] Error updating step:", error);
443
447
  }
444
448
  }, [enabled, debug]);
445
- const trackComplete = react.useCallback(async () => {
449
+ const trackComplete = React2.useCallback(async () => {
446
450
  if (!enabled || !analyticsIdRef.current) return;
447
451
  const apiUrl = window.__SITE_KIT_API_URL__;
448
452
  const apiKey = window.__SITE_KIT_API_KEY__;
@@ -508,6 +512,42 @@ function getDeviceType() {
508
512
  return "desktop";
509
513
  }
510
514
 
515
+ // src/forms/fetchWithRetry.ts
516
+ function backoffDelay(attempt, response) {
517
+ if (response?.status === 429) {
518
+ const header = response.headers.get("retry-after");
519
+ const seconds = header ? parseInt(header, 10) : NaN;
520
+ if (!Number.isNaN(seconds) && seconds > 0) return Math.min(seconds * 1e3, 1e4);
521
+ }
522
+ return Math.min(8e3, 500 * 2 ** attempt);
523
+ }
524
+ async function fetchWithRetry(url, init, options = {}) {
525
+ const retries = options.retries ?? 2;
526
+ const timeoutMs = options.timeoutMs ?? 15e3;
527
+ let lastError;
528
+ for (let attempt = 0; attempt <= retries; attempt++) {
529
+ const controller = new AbortController();
530
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
531
+ try {
532
+ const response = await fetch(url, { ...init, signal: controller.signal });
533
+ clearTimeout(timer);
534
+ if ((response.status === 429 || response.status >= 500) && attempt < retries) {
535
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay(attempt, response)));
536
+ continue;
537
+ }
538
+ return response;
539
+ } catch (error) {
540
+ clearTimeout(timer);
541
+ lastError = error;
542
+ if (attempt < retries) {
543
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay(attempt)));
544
+ continue;
545
+ }
546
+ }
547
+ }
548
+ throw lastError instanceof Error ? lastError : new Error("Network request failed after multiple attempts. Please try again.");
549
+ }
550
+
511
551
  // src/forms/recaptcha.ts
512
552
  function getSiteKey(explicitSiteKey) {
513
553
  if (typeof window === "undefined") return void 0;
@@ -515,8 +555,9 @@ function getSiteKey(explicitSiteKey) {
515
555
  const win = window;
516
556
  return win.__SITE_KIT_RECAPTCHA_SITE_KEY__ ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
517
557
  }
558
+ var RECAPTCHA_LOAD_TIMEOUT_MS = 1e4;
518
559
  function loadScript(siteKey) {
519
- return new Promise((resolve, reject) => {
560
+ return new Promise((resolve) => {
520
561
  if (typeof window === "undefined") {
521
562
  resolve(null);
522
563
  return;
@@ -526,20 +567,35 @@ function loadScript(siteKey) {
526
567
  resolve(win.grecaptcha);
527
568
  return;
528
569
  }
570
+ let settled = false;
571
+ const finish = (value) => {
572
+ if (settled) return;
573
+ settled = true;
574
+ resolve(value);
575
+ };
576
+ const giveUp = setTimeout(() => finish(null), RECAPTCHA_LOAD_TIMEOUT_MS);
577
+ const waitForReady = () => {
578
+ if (settled) return;
579
+ if (win.grecaptcha?.enterprise) {
580
+ clearTimeout(giveUp);
581
+ finish(win.grecaptcha);
582
+ return;
583
+ }
584
+ setTimeout(waitForReady, 50);
585
+ };
529
586
  const existing = document.querySelector('script[src*="recaptcha/enterprise"]');
530
587
  if (existing) {
531
- const check = () => {
532
- if (win.grecaptcha?.enterprise) resolve(win.grecaptcha);
533
- else setTimeout(check, 50);
534
- };
535
- check();
588
+ waitForReady();
536
589
  return;
537
590
  }
538
591
  const script = document.createElement("script");
539
592
  script.src = `https://www.google.com/recaptcha/enterprise.js?render=${siteKey}`;
540
593
  script.async = true;
541
- script.onload = () => resolve(win.grecaptcha);
542
- script.onerror = () => reject(new Error("reCAPTCHA script failed to load"));
594
+ script.onload = () => waitForReady();
595
+ script.onerror = () => {
596
+ clearTimeout(giveUp);
597
+ finish(null);
598
+ };
543
599
  document.head.appendChild(script);
544
600
  });
545
601
  }
@@ -557,21 +613,83 @@ async function getRecaptchaToken(explicitSiteKey) {
557
613
  }
558
614
  }
559
615
 
560
- // src/forms/defaultSuccessMessage.ts
561
- var DEFAULT_FORM_SUCCESS_MESSAGE = "Thank you for your submission! We will be in touch soon.";
562
-
563
- // src/forms/useForm.ts
616
+ // src/forms/submitForm.ts
617
+ var DEFAULT_HONEYPOT_FIELD = "_sk_hp_url";
618
+ function getApiConfig() {
619
+ const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
620
+ const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
621
+ return { apiUrl, apiKey };
622
+ }
564
623
  function getUTMParams() {
565
624
  if (typeof window === "undefined") return {};
566
625
  const params = new URLSearchParams(window.location.search);
567
- return {
568
- utm_source: params.get("utm_source"),
569
- utm_medium: params.get("utm_medium"),
570
- utm_campaign: params.get("utm_campaign"),
571
- utm_term: params.get("utm_term"),
572
- utm_content: params.get("utm_content")
626
+ const out = {};
627
+ for (const key of ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]) {
628
+ out[key] = params.get(key);
629
+ }
630
+ return out;
631
+ }
632
+ async function submitForm({
633
+ config: config2,
634
+ values,
635
+ honeypotValue,
636
+ formLoadedAt
637
+ }) {
638
+ const { apiUrl, apiKey } = getApiConfig();
639
+ if (!apiKey) {
640
+ throw new Error("API key is required. Set SONOR_API_KEY in your environment.");
641
+ }
642
+ const recaptchaToken = config2.recaptcha_enabled ? await getRecaptchaToken(config2.recaptcha_site_key) : null;
643
+ if (config2.recaptcha_enabled && !recaptchaToken) {
644
+ throw new Error("Verification could not be completed. Please try again.");
645
+ }
646
+ const honeypotFieldName = config2.honeypot_field || DEFAULT_HONEYPOT_FIELD;
647
+ const utm = getUTMParams();
648
+ const payload = {
649
+ formId: config2.id,
650
+ form_id: config2.id,
651
+ data: {
652
+ ...values,
653
+ ...config2.honeypot_enabled ? { [honeypotFieldName]: honeypotValue } : {}
654
+ },
655
+ metadata: {
656
+ pageUrl: typeof window !== "undefined" ? window.location.href : null,
657
+ referrer: typeof document !== "undefined" ? document.referrer || null : null,
658
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null,
659
+ sessionId: typeof sessionStorage !== "undefined" ? sessionStorage.getItem("_sk_sid") : null,
660
+ recaptchaToken: recaptchaToken || void 0,
661
+ utmSource: utm.utm_source,
662
+ utmMedium: utm.utm_medium,
663
+ utmCampaign: utm.utm_campaign,
664
+ _formLoadedAt: formLoadedAt
665
+ }
573
666
  };
667
+ const response = await fetchWithRetry(
668
+ `${apiUrl}/api/public/forms/submit`,
669
+ {
670
+ method: "POST",
671
+ headers: {
672
+ "Content-Type": "application/json",
673
+ Authorization: `Bearer ${apiKey}`
674
+ },
675
+ body: JSON.stringify(payload)
676
+ },
677
+ { retries: 2, timeoutMs: 2e4 }
678
+ );
679
+ const data = await response.json().catch(() => null);
680
+ if (!response.ok) {
681
+ const msg = (data?.message ?? data?.error ?? (response.statusText || `HTTP ${response.status}`)).toString().trim();
682
+ throw new Error(
683
+ msg ? `Failed to submit form: ${msg}` : `Failed to submit form (${response.status})`
684
+ );
685
+ }
686
+ return data;
574
687
  }
688
+
689
+ // src/forms/defaultSuccessMessage.ts
690
+ var DEFAULT_FORM_SUCCESS_MESSAGE = "Thank you for your submission! We will be in touch soon.";
691
+
692
+ // src/forms/useForm.ts
575
693
  function useForm(formIdOrSlug, options = {}) {
576
694
  const {
577
695
  projectId: optionsProjectId,
@@ -581,32 +699,38 @@ function useForm(formIdOrSlug, options = {}) {
581
699
  redirectUrl
582
700
  } = options;
583
701
  const projectId = optionsProjectId || (typeof window !== "undefined" ? window.__SITE_KIT_PROJECT_ID__ : void 0);
584
- const [form, setForm] = react.useState(null);
585
- const [isLoading, setIsLoading] = react.useState(true);
586
- const [fetchError, setFetchError] = react.useState(null);
587
- const [values, setValuesState] = react.useState(initialValues);
588
- const [errors, setErrors] = react.useState({});
589
- const [step, setStep] = react.useState(1);
590
- const [isSubmitting, setIsSubmitting] = react.useState(false);
591
- const [isComplete, setIsComplete] = react.useState(false);
702
+ const [form, setForm] = React2.useState(null);
703
+ const [isLoading, setIsLoading] = React2.useState(true);
704
+ const [fetchError, setFetchError] = React2.useState(null);
705
+ const [values, setValuesState] = React2.useState(initialValues);
706
+ const [errors, setErrors] = React2.useState({});
707
+ const [step, setStep] = React2.useState(1);
708
+ const [isSubmitting, setIsSubmitting] = React2.useState(false);
709
+ const [isComplete, setIsComplete] = React2.useState(false);
710
+ const [submitError, setSubmitError] = React2.useState(null);
711
+ const isSubmittingRef = React2.useRef(false);
712
+ const formLoadedAt = React2.useRef(Date.now());
592
713
  const totalSteps = form?.total_steps || 1;
593
714
  const isMultiStep = form?.is_multi_step || false;
594
715
  const { trackStepChange, trackComplete } = useFormTracking({
595
716
  formId: form?.id || "",
596
717
  totalSteps
597
718
  });
598
- react.useEffect(() => {
719
+ React2.useEffect(() => {
599
720
  const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
600
- const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
601
- if (!apiKey) {
602
- setFetchError(new Error("API key is required. Provide it via SiteKitProvider (apiKey) or set __SITE_KIT_API_KEY__."));
603
- setIsLoading(false);
604
- return;
605
- }
606
721
  async function fetchForm() {
607
722
  try {
608
723
  setIsLoading(true);
609
724
  setFetchError(null);
725
+ const readApiKey = () => typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
726
+ let apiKey = readApiKey();
727
+ for (let i = 0; i < 15 && !apiKey; i++) {
728
+ await new Promise((r) => setTimeout(r, 100));
729
+ apiKey = readApiKey();
730
+ }
731
+ if (!apiKey) {
732
+ throw new Error("API key is required. Provide it via SiteKitLayout/SiteKitProvider or set SONOR_API_KEY.");
733
+ }
610
734
  const url = `${apiUrl}/api/public/forms/config`;
611
735
  const init = {
612
736
  method: "POST",
@@ -618,15 +742,7 @@ function useForm(formIdOrSlug, options = {}) {
618
742
  formIdOrSlug
619
743
  })
620
744
  };
621
- const maxAttempts = 4;
622
- let response = await fetch(url, init);
623
- for (let attempt = 1; attempt < maxAttempts && response.status === 429; attempt++) {
624
- const ra = response.headers.get("retry-after");
625
- const retryAfterSec = ra ? parseInt(ra, 10) : NaN;
626
- const delayMs = !Number.isNaN(retryAfterSec) && retryAfterSec > 0 ? retryAfterSec * 1e3 : Math.min(8e3, 400 * 2 ** (attempt - 1));
627
- await new Promise((r) => setTimeout(r, delayMs));
628
- response = await fetch(url, init);
629
- }
745
+ const response = await fetchWithRetry(url, init, { retries: 3, timeoutMs: 15e3 });
630
746
  if (!response.ok) {
631
747
  throw new Error(`Failed to fetch form: ${response.statusText}`);
632
748
  }
@@ -667,14 +783,14 @@ function useForm(formIdOrSlug, options = {}) {
667
783
  }
668
784
  fetchForm();
669
785
  }, [formIdOrSlug, projectId]);
670
- const allFields = react.useMemo(() => form?.fields || [], [form]);
671
- const fields = react.useMemo(() => {
786
+ const allFields = React2.useMemo(() => form?.fields || [], [form]);
787
+ const fields = React2.useMemo(() => {
672
788
  if (!isMultiStep) return allFields;
673
789
  const currentStepConfig = form?.steps?.find((s) => s.step_number === step);
674
790
  if (!currentStepConfig) return allFields;
675
791
  return allFields.filter((f) => f.step_id === currentStepConfig.id);
676
792
  }, [form, step, allFields, isMultiStep]);
677
- const isFieldVisible = react.useCallback((field2) => {
793
+ const isFieldVisible = React2.useCallback((field2) => {
678
794
  if (!field2.conditional?.show_when) return true;
679
795
  const { field: condField, operator, value } = field2.conditional.show_when;
680
796
  const fieldValue = values[condField];
@@ -699,11 +815,12 @@ function useForm(formIdOrSlug, options = {}) {
699
815
  return true;
700
816
  }
701
817
  }, [values]);
702
- const visibleFields = react.useMemo(() => {
818
+ const visibleFields = React2.useMemo(() => {
703
819
  return fields.filter(isFieldVisible);
704
820
  }, [fields, isFieldVisible]);
705
- const validateField = react.useCallback((field2) => {
821
+ const validateField = React2.useCallback((field2) => {
706
822
  if (!isFieldVisible(field2)) return null;
823
+ if (["heading", "section_header", "paragraph", "hidden"].includes(field2.field_type)) return null;
707
824
  const value = values[field2.slug];
708
825
  const rules = field2.validation || {};
709
826
  if (field2.is_required && (!value || value === "")) {
@@ -723,8 +840,13 @@ function useForm(formIdOrSlug, options = {}) {
723
840
  if (rules.max !== void 0 && Number(value) > rules.max) {
724
841
  return `${field2.label} must be no more than ${rules.max}`;
725
842
  }
726
- if (rules.pattern && !new RegExp(rules.pattern).test(strValue)) {
727
- return rules.custom_error || `${field2.label} is invalid`;
843
+ if (rules.pattern) {
844
+ try {
845
+ if (!new RegExp(rules.pattern).test(strValue)) {
846
+ return rules.custom_error || `${field2.label} is invalid`;
847
+ }
848
+ } catch {
849
+ }
728
850
  }
729
851
  if (field2.field_type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(strValue)) {
730
852
  return "Please enter a valid email address";
@@ -734,7 +856,7 @@ function useForm(formIdOrSlug, options = {}) {
734
856
  }
735
857
  return null;
736
858
  }, [values, isFieldVisible]);
737
- const validate = react.useCallback(() => {
859
+ const validate = React2.useCallback(() => {
738
860
  const newErrors = {};
739
861
  for (const field2 of fields) {
740
862
  const error = validateField(field2);
@@ -745,7 +867,24 @@ function useForm(formIdOrSlug, options = {}) {
745
867
  setErrors(newErrors);
746
868
  return Object.keys(newErrors).length === 0;
747
869
  }, [fields, validateField]);
748
- const setFieldValue = react.useCallback((key, value) => {
870
+ const validateAll = React2.useCallback(() => {
871
+ const newErrors = {};
872
+ for (const field2 of allFields) {
873
+ const error = validateField(field2);
874
+ if (error) newErrors[field2.slug] = error;
875
+ }
876
+ setErrors(newErrors);
877
+ return Object.keys(newErrors).length === 0;
878
+ }, [allFields, validateField]);
879
+ const buildVisibleValues = React2.useCallback(() => {
880
+ const out = {};
881
+ for (const field2 of allFields) {
882
+ if (!isFieldVisible(field2)) continue;
883
+ if (field2.slug in values) out[field2.slug] = values[field2.slug];
884
+ }
885
+ return out;
886
+ }, [allFields, values, isFieldVisible]);
887
+ const setFieldValue = React2.useCallback((key, value) => {
749
888
  setValuesState((prev) => ({ ...prev, [key]: value }));
750
889
  setErrors((prev) => {
751
890
  if (prev[key]) {
@@ -756,17 +895,17 @@ function useForm(formIdOrSlug, options = {}) {
756
895
  return prev;
757
896
  });
758
897
  }, []);
759
- const setValues = react.useCallback((newValues) => {
898
+ const setValues = React2.useCallback((newValues) => {
760
899
  setValuesState((prev) => ({ ...prev, ...newValues }));
761
900
  }, []);
762
- const clearErrors = react.useCallback(() => {
901
+ const clearErrors = React2.useCallback(() => {
763
902
  setErrors({});
764
903
  }, []);
765
904
  const canGoNext = step < totalSteps;
766
905
  const canGoPrev = step > 1;
767
906
  const isLastStep = step === totalSteps;
768
907
  const progress = Math.round(step / totalSteps * 100);
769
- const nextStep = react.useCallback(() => {
908
+ const nextStep = React2.useCallback(() => {
770
909
  if (!validate()) return false;
771
910
  if (step < totalSteps) {
772
911
  const newStep = step + 1;
@@ -776,64 +915,33 @@ function useForm(formIdOrSlug, options = {}) {
776
915
  }
777
916
  return false;
778
917
  }, [step, totalSteps, validate, trackStepChange]);
779
- const prevStep = react.useCallback(() => {
918
+ const prevStep = React2.useCallback(() => {
780
919
  if (step > 1) {
781
920
  const newStep = step - 1;
782
921
  setStep(newStep);
783
922
  trackStepChange(newStep);
784
923
  }
785
924
  }, [step, trackStepChange]);
786
- const goToStep = react.useCallback((targetStep) => {
925
+ const goToStep = React2.useCallback((targetStep) => {
787
926
  if (targetStep >= 1 && targetStep <= totalSteps) {
788
927
  setStep(targetStep);
789
928
  trackStepChange(targetStep);
790
929
  }
791
930
  }, [totalSteps, trackStepChange]);
792
- const submit = react.useCallback(async () => {
931
+ const submit = React2.useCallback(async () => {
793
932
  if (!form) return;
794
- if (!validate()) return;
933
+ if (isSubmittingRef.current) return;
934
+ if (!validateAll()) return;
935
+ isSubmittingRef.current = true;
795
936
  setIsSubmitting(true);
937
+ setSubmitError(null);
796
938
  try {
797
- const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
798
- const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
799
- if (!apiKey) {
800
- throw new Error("API key is required. Set SONOR_API_KEY in your .env");
801
- }
802
- const honeypotFieldName = form.honeypot_field || "website";
803
- const recaptchaToken = form.recaptcha_enabled ? await getRecaptchaToken(form.recaptcha_site_key) : null;
804
- if (form.recaptcha_enabled && !recaptchaToken) {
805
- throw new Error("reCAPTCHA is required but could not be initialized.");
806
- }
807
- const utm = getUTMParams();
808
- const submission = {
809
- formId: form.id,
810
- data: {
811
- ...values,
812
- ...form.honeypot_enabled ? { [honeypotFieldName]: "" } : {}
813
- },
814
- metadata: {
815
- pageUrl: typeof window !== "undefined" ? window.location.href : null,
816
- referrer: typeof document !== "undefined" ? document.referrer || null : null,
817
- userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null,
818
- sessionId: typeof sessionStorage !== "undefined" ? sessionStorage.getItem("_sk_sid") : null,
819
- recaptchaToken: recaptchaToken || void 0,
820
- utmSource: utm.utm_source,
821
- utmMedium: utm.utm_medium,
822
- utmCampaign: utm.utm_campaign
823
- }
824
- };
825
- const response = await fetch(`${apiUrl}/api/public/forms/submit`, {
826
- method: "POST",
827
- headers: {
828
- "Content-Type": "application/json",
829
- "Authorization": `Bearer ${apiKey}`
830
- },
831
- body: JSON.stringify(submission)
939
+ const data = await submitForm({
940
+ config: form,
941
+ values: buildVisibleValues(),
942
+ honeypotValue: "",
943
+ formLoadedAt: formLoadedAt.current
832
944
  });
833
- if (!response.ok) {
834
- throw new Error(`Form submission failed: ${response.statusText}`);
835
- }
836
- const data = await response.json();
837
945
  trackComplete();
838
946
  setIsComplete(true);
839
947
  onSuccess?.(data);
@@ -843,21 +951,25 @@ function useForm(formIdOrSlug, options = {}) {
843
951
  }
844
952
  } catch (error) {
845
953
  console.error("[useForm] Submission error:", error);
954
+ setSubmitError("Something went wrong sending your submission. Please try again.");
846
955
  onError?.(error);
847
956
  } finally {
957
+ isSubmittingRef.current = false;
848
958
  setIsSubmitting(false);
849
959
  }
850
- }, [form, values, validate, trackComplete, onSuccess, onError, redirectUrl]);
851
- const reset = react.useCallback(() => {
960
+ }, [form, validateAll, buildVisibleValues, trackComplete, onSuccess, onError, redirectUrl]);
961
+ const reset = React2.useCallback(() => {
852
962
  setValuesState(initialValues);
853
963
  setErrors({});
854
964
  setStep(1);
855
965
  setIsComplete(false);
966
+ setSubmitError(null);
856
967
  }, [initialValues]);
857
968
  return {
858
969
  form,
859
970
  isLoading,
860
971
  fetchError,
972
+ submitError,
861
973
  allFields,
862
974
  fields,
863
975
  visibleFields,
@@ -923,13 +1035,13 @@ function DatePicker({
923
1035
  }) {
924
1036
  const today = /* @__PURE__ */ new Date();
925
1037
  const todayStr = toDateStr(today.getFullYear(), today.getMonth(), today.getDate());
926
- const effectiveMin = minDate || todayStr;
1038
+ const effectiveMin = minDate || "";
927
1039
  const parsed = parseDate(value);
928
- const [viewYear, setViewYear] = react.useState(parsed?.year || today.getFullYear());
929
- const [viewMonth, setViewMonth] = react.useState(parsed?.month ?? today.getMonth());
930
- const [open, setOpen] = react.useState(false);
931
- const containerRef = react.useRef(null);
932
- react.useEffect(() => {
1040
+ const [viewYear, setViewYear] = React2.useState(parsed?.year || today.getFullYear());
1041
+ const [viewMonth, setViewMonth] = React2.useState(parsed?.month ?? today.getMonth());
1042
+ const [open, setOpen] = React2.useState(false);
1043
+ const containerRef = React2.useRef(null);
1044
+ React2.useEffect(() => {
933
1045
  if (!open) return;
934
1046
  const handler = (e) => {
935
1047
  if (containerRef.current && !containerRef.current.contains(e.target)) {
@@ -939,7 +1051,7 @@ function DatePicker({
939
1051
  document.addEventListener("mousedown", handler);
940
1052
  return () => document.removeEventListener("mousedown", handler);
941
1053
  }, [open]);
942
- react.useEffect(() => {
1054
+ React2.useEffect(() => {
943
1055
  if (!open) return;
944
1056
  const handler = (e) => {
945
1057
  if (e.key === "Escape") setOpen(false);
@@ -947,32 +1059,32 @@ function DatePicker({
947
1059
  window.addEventListener("keydown", handler);
948
1060
  return () => window.removeEventListener("keydown", handler);
949
1061
  }, [open]);
950
- const prevMonth = react.useCallback(() => {
1062
+ const prevMonth = React2.useCallback(() => {
951
1063
  if (viewMonth === 0) {
952
1064
  setViewMonth(11);
953
1065
  setViewYear((y) => y - 1);
954
1066
  } else setViewMonth((m) => m - 1);
955
1067
  }, [viewMonth]);
956
- const nextMonth = react.useCallback(() => {
1068
+ const nextMonth = React2.useCallback(() => {
957
1069
  if (viewMonth === 11) {
958
1070
  setViewMonth(0);
959
1071
  setViewYear((y) => y + 1);
960
1072
  } else setViewMonth((m) => m + 1);
961
1073
  }, [viewMonth]);
962
- const selectDay = react.useCallback((day) => {
1074
+ const selectDay = React2.useCallback((day) => {
963
1075
  const dateStr = toDateStr(viewYear, viewMonth, day);
964
1076
  onChange(dateStr);
965
1077
  setOpen(false);
966
1078
  }, [viewYear, viewMonth, onChange]);
967
- const isDisabled = react.useCallback((day) => {
1079
+ const isDisabled = React2.useCallback((day) => {
968
1080
  const dateStr = toDateStr(viewYear, viewMonth, day);
969
1081
  return dateStr < effectiveMin;
970
1082
  }, [viewYear, viewMonth, effectiveMin]);
971
- const isSelected = react.useCallback((day) => {
1083
+ const isSelected = React2.useCallback((day) => {
972
1084
  if (!value) return false;
973
1085
  return toDateStr(viewYear, viewMonth, day) === value;
974
1086
  }, [viewYear, viewMonth, value]);
975
- const isToday = react.useCallback((day) => {
1087
+ const isToday = React2.useCallback((day) => {
976
1088
  return toDateStr(viewYear, viewMonth, day) === todayStr;
977
1089
  }, [viewYear, viewMonth, todayStr]);
978
1090
  const daysInMonth = getDaysInMonth(viewYear, viewMonth);
@@ -1349,20 +1461,13 @@ function FormField({ field: field2, value, error, onChange, classPrefix = "sk-fo
1349
1461
  }
1350
1462
  ),
1351
1463
  field2.field_type === "file" && /* @__PURE__ */ jsxRuntime.jsx(
1352
- "input",
1464
+ FileUploadField,
1353
1465
  {
1354
- id: inputId,
1355
- className: `${classPrefix}__input ${classPrefix}__input--file`,
1356
- type: "file",
1357
- name: field2.slug,
1358
- required: field2.is_required,
1359
- onChange: (e) => {
1360
- const file = e.target.files?.[0];
1361
- if (file) {
1362
- onChange(file.name);
1363
- }
1364
- },
1365
- style: baseInputStyle
1466
+ field: field2,
1467
+ inputId,
1468
+ classPrefix,
1469
+ baseInputStyle,
1470
+ onChange
1366
1471
  }
1367
1472
  ),
1368
1473
  field2.field_type === "rating" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: `${classPrefix}__rating`, style: { display: "flex", gap: 4 }, children: [1, 2, 3, 4, 5].map((star) => /* @__PURE__ */ jsxRuntime.jsx(
@@ -1402,6 +1507,79 @@ function FormField({ field: field2, value, error, onChange, classPrefix = "sk-fo
1402
1507
  field2.help_text && !error && field2.field_type !== "checkbox" && /* @__PURE__ */ jsxRuntime.jsx("p", { className: `${classPrefix}__help`, style: helpStyle, children: field2.help_text })
1403
1508
  ] });
1404
1509
  }
1510
+ function FileUploadField({
1511
+ field: field2,
1512
+ inputId,
1513
+ classPrefix,
1514
+ baseInputStyle,
1515
+ onChange
1516
+ }) {
1517
+ const [status, setStatus] = React2__default.default.useState("idle");
1518
+ const [fileName, setFileName] = React2__default.default.useState("");
1519
+ const handleFile = async (e) => {
1520
+ const file = e.target.files?.[0];
1521
+ if (!file) return;
1522
+ setFileName(file.name);
1523
+ setStatus("uploading");
1524
+ onChange("");
1525
+ try {
1526
+ const url = await uploadFormFile(file);
1527
+ onChange(url);
1528
+ setStatus("idle");
1529
+ } catch (err) {
1530
+ console.error("[Forms] File upload failed:", err);
1531
+ onChange("");
1532
+ setStatus("error");
1533
+ }
1534
+ };
1535
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1536
+ /* @__PURE__ */ jsxRuntime.jsx(
1537
+ "input",
1538
+ {
1539
+ id: inputId,
1540
+ className: `${classPrefix}__input ${classPrefix}__input--file`,
1541
+ type: "file",
1542
+ name: field2.slug,
1543
+ required: field2.is_required,
1544
+ disabled: status === "uploading",
1545
+ onChange: handleFile,
1546
+ style: baseInputStyle
1547
+ }
1548
+ ),
1549
+ status === "uploading" && /* @__PURE__ */ jsxRuntime.jsxs("p", { style: { color: "var(--sk-text-tertiary, #6b7280)", fontSize: 14, marginTop: 4 }, children: [
1550
+ "Uploading ",
1551
+ fileName,
1552
+ "\u2026"
1553
+ ] }),
1554
+ status === "error" && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "alert", style: { color: "var(--sk-error, #ef4444)", fontSize: 14, marginTop: 4 }, children: "Upload failed. Please choose the file again." })
1555
+ ] });
1556
+ }
1557
+ async function uploadFormFile(file) {
1558
+ const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
1559
+ const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
1560
+ if (!apiKey) {
1561
+ throw new Error("API key is required to upload files.");
1562
+ }
1563
+ const body = new FormData();
1564
+ body.append("file", file);
1565
+ const response = await fetchWithRetry(
1566
+ `${apiUrl}/api/public/forms/upload`,
1567
+ {
1568
+ method: "POST",
1569
+ headers: { Authorization: `Bearer ${apiKey}` },
1570
+ body
1571
+ },
1572
+ { retries: 1, timeoutMs: 6e4 }
1573
+ );
1574
+ if (!response.ok) {
1575
+ throw new Error(`Upload failed (${response.status})`);
1576
+ }
1577
+ const data = await response.json().catch(() => null);
1578
+ if (!data?.url) {
1579
+ throw new Error("Upload did not return a URL.");
1580
+ }
1581
+ return data.url;
1582
+ }
1405
1583
  function FormClient({
1406
1584
  config: config2,
1407
1585
  className,
@@ -1409,19 +1587,21 @@ function FormClient({
1409
1587
  onError,
1410
1588
  customRender
1411
1589
  }) {
1412
- const [values, setValues] = react.useState({});
1413
- const [errors, setErrors] = react.useState({});
1414
- const [step, setStep] = react.useState(1);
1415
- const [isSubmitting, setIsSubmitting] = react.useState(false);
1416
- const [isComplete, setIsComplete] = react.useState(false);
1417
- const [honeypotValue, setHoneypotValue] = react.useState("");
1418
- const honeypotFieldName = config2.honeypot_field || "website";
1419
- const formLoadedAt = react.useRef(Date.now());
1590
+ const [values, setValues] = React2.useState({});
1591
+ const [errors, setErrors] = React2.useState({});
1592
+ const [step, setStep] = React2.useState(1);
1593
+ const [isSubmitting, setIsSubmitting] = React2.useState(false);
1594
+ const [isComplete, setIsComplete] = React2.useState(false);
1595
+ const [submitError, setSubmitError] = React2.useState(null);
1596
+ const [honeypotValue, setHoneypotValue] = React2.useState("");
1597
+ const honeypotFieldName = config2.honeypot_field || DEFAULT_HONEYPOT_FIELD;
1598
+ const formLoadedAt = React2.useRef(Date.now());
1599
+ const isSubmittingRef = React2.useRef(false);
1420
1600
  const { trackStepChange, trackComplete } = useFormTracking({
1421
1601
  formId: config2.id,
1422
1602
  totalSteps: config2.total_steps
1423
1603
  });
1424
- const currentFields = react.useMemo(() => {
1604
+ const currentFields = React2.useMemo(() => {
1425
1605
  if (!config2.is_multi_step) {
1426
1606
  return config2.fields || [];
1427
1607
  }
@@ -1429,7 +1609,7 @@ function FormClient({
1429
1609
  if (!currentStepConfig) return config2.fields || [];
1430
1610
  return (config2.fields || []).filter((f) => f.step_id === currentStepConfig.id);
1431
1611
  }, [config2, step]);
1432
- const isFieldVisible = react.useCallback((field2) => {
1612
+ const isFieldVisible = React2.useCallback((field2) => {
1433
1613
  if (!field2.conditional?.show_when) return true;
1434
1614
  const { field: condField, operator, value } = field2.conditional.show_when;
1435
1615
  const fieldValue = values[condField];
@@ -1454,7 +1634,7 @@ function FormClient({
1454
1634
  return true;
1455
1635
  }
1456
1636
  }, [values]);
1457
- const validateField = react.useCallback((field2) => {
1637
+ const validateField = React2.useCallback((field2) => {
1458
1638
  if (!isFieldVisible(field2)) return null;
1459
1639
  if (["heading", "section_header", "paragraph", "hidden"].includes(field2.field_type)) return null;
1460
1640
  const value = values[field2.slug];
@@ -1476,8 +1656,13 @@ function FormClient({
1476
1656
  if (rules.max !== void 0 && Number(value) > rules.max) {
1477
1657
  return `${field2.label} must be no more than ${rules.max}`;
1478
1658
  }
1479
- if (rules.pattern && !new RegExp(rules.pattern).test(strValue)) {
1480
- return rules.custom_error || `${field2.label} is invalid`;
1659
+ if (rules.pattern) {
1660
+ try {
1661
+ if (!new RegExp(rules.pattern).test(strValue)) {
1662
+ return rules.custom_error || `${field2.label} is invalid`;
1663
+ }
1664
+ } catch {
1665
+ }
1481
1666
  }
1482
1667
  if (field2.field_type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(strValue)) {
1483
1668
  return "Please enter a valid email address";
@@ -1487,7 +1672,7 @@ function FormClient({
1487
1672
  }
1488
1673
  return null;
1489
1674
  }, [values, isFieldVisible]);
1490
- const validateStep = react.useCallback(() => {
1675
+ const validateStep = React2.useCallback(() => {
1491
1676
  const newErrors = {};
1492
1677
  for (const field2 of currentFields) {
1493
1678
  const error = validateField(field2);
@@ -1498,7 +1683,36 @@ function FormClient({
1498
1683
  setErrors(newErrors);
1499
1684
  return Object.keys(newErrors).length === 0;
1500
1685
  }, [currentFields, validateField]);
1501
- const setFieldValue = react.useCallback((key, value) => {
1686
+ const validateAll = React2.useCallback(() => {
1687
+ const allFields = config2.fields || [];
1688
+ const newErrors = {};
1689
+ for (const field2 of allFields) {
1690
+ const error = validateField(field2);
1691
+ if (error) newErrors[field2.slug] = error;
1692
+ }
1693
+ setErrors(newErrors);
1694
+ if (Object.keys(newErrors).length === 0) return { ok: true, firstErrorStep: null };
1695
+ let firstErrorStep = null;
1696
+ if (config2.is_multi_step && config2.steps) {
1697
+ const ordered = [...config2.steps].sort((a, b) => a.step_number - b.step_number);
1698
+ for (const s of ordered) {
1699
+ if (allFields.some((f) => f.step_id === s.id && newErrors[f.slug])) {
1700
+ firstErrorStep = s.step_number;
1701
+ break;
1702
+ }
1703
+ }
1704
+ }
1705
+ return { ok: false, firstErrorStep };
1706
+ }, [config2, validateField]);
1707
+ const buildVisibleValues = React2.useCallback(() => {
1708
+ const out = {};
1709
+ for (const field2 of config2.fields || []) {
1710
+ if (!isFieldVisible(field2)) continue;
1711
+ if (field2.slug in values) out[field2.slug] = values[field2.slug];
1712
+ }
1713
+ return out;
1714
+ }, [config2, values, isFieldVisible]);
1715
+ const setFieldValue = React2.useCallback((key, value) => {
1502
1716
  setValues((prev) => ({ ...prev, [key]: value }));
1503
1717
  if (errors[key]) {
1504
1718
  setErrors((prev) => {
@@ -1508,7 +1722,7 @@ function FormClient({
1508
1722
  });
1509
1723
  }
1510
1724
  }, [errors]);
1511
- const nextStep = react.useCallback(() => {
1725
+ const nextStep = React2.useCallback(() => {
1512
1726
  if (!validateStep()) return false;
1513
1727
  if (step < config2.total_steps) {
1514
1728
  const newStep = step + 1;
@@ -1518,67 +1732,36 @@ function FormClient({
1518
1732
  }
1519
1733
  return false;
1520
1734
  }, [step, config2.total_steps, validateStep, trackStepChange]);
1521
- const prevStep = react.useCallback(() => {
1735
+ const prevStep = React2.useCallback(() => {
1522
1736
  if (step > 1) {
1523
1737
  const newStep = step - 1;
1524
1738
  setStep(newStep);
1525
1739
  trackStepChange(newStep);
1526
1740
  }
1527
1741
  }, [step, trackStepChange]);
1528
- const goToStep = react.useCallback((targetStep) => {
1742
+ const goToStep = React2.useCallback((targetStep) => {
1529
1743
  if (targetStep >= 1 && targetStep <= config2.total_steps) {
1530
1744
  setStep(targetStep);
1531
1745
  trackStepChange(targetStep);
1532
1746
  }
1533
1747
  }, [config2.total_steps, trackStepChange]);
1534
- const submit = react.useCallback(async () => {
1535
- if (!validateStep()) return;
1748
+ const submit = React2.useCallback(async () => {
1749
+ if (isSubmittingRef.current) return;
1750
+ const { ok, firstErrorStep } = validateAll();
1751
+ if (!ok) {
1752
+ if (firstErrorStep != null) setStep(firstErrorStep);
1753
+ return;
1754
+ }
1755
+ isSubmittingRef.current = true;
1536
1756
  setIsSubmitting(true);
1757
+ setSubmitError(null);
1537
1758
  try {
1538
- const apiUrl = typeof window !== "undefined" ? window.__SITE_KIT_API_URL__ || "https://api.sonor.io" : "https://api.sonor.io";
1539
- const apiKey = typeof window !== "undefined" ? window.__SITE_KIT_API_KEY__ : void 0;
1540
- if (!apiKey) {
1541
- throw new Error("API key is required. Set SONOR_API_KEY in your .env");
1542
- }
1543
- const recaptchaToken = config2.recaptcha_enabled ? await getRecaptchaToken(config2.recaptcha_site_key) : null;
1544
- if (config2.recaptcha_enabled && !recaptchaToken) {
1545
- throw new Error("reCAPTCHA is required but could not be initialized.");
1546
- }
1547
- const utm = getUTMParams2();
1548
- const submission = {
1549
- form_id: config2.id,
1550
- project_id: config2.project_id,
1551
- data: {
1552
- ...values,
1553
- ...config2.honeypot_enabled ? { [honeypotFieldName]: honeypotValue } : {}
1554
- },
1555
- routing_type: config2.form_type,
1556
- status: "new",
1557
- metadata: {
1558
- pageUrl: typeof window !== "undefined" ? window.location.href : null,
1559
- referrer: typeof document !== "undefined" ? document.referrer || null : null,
1560
- userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null,
1561
- sessionId: typeof sessionStorage !== "undefined" ? sessionStorage.getItem("_sk_sid") : null,
1562
- recaptchaToken: recaptchaToken || void 0,
1563
- utmSource: utm.utm_source,
1564
- utmMedium: utm.utm_medium,
1565
- utmCampaign: utm.utm_campaign,
1566
- _formLoadedAt: formLoadedAt.current
1567
- }
1568
- };
1569
- const response = await fetch(`${apiUrl}/api/public/forms/submit`, {
1570
- method: "POST",
1571
- headers: {
1572
- "Content-Type": "application/json",
1573
- "Authorization": `Bearer ${apiKey}`
1574
- },
1575
- body: JSON.stringify(submission)
1759
+ const data = await submitForm({
1760
+ config: config2,
1761
+ values: buildVisibleValues(),
1762
+ honeypotValue: config2.honeypot_enabled ? honeypotValue : "",
1763
+ formLoadedAt: formLoadedAt.current
1576
1764
  });
1577
- const data = await response.json().catch(() => null);
1578
- if (!response.ok) {
1579
- const msg = (data?.message ?? data?.error ?? (response.statusText || `HTTP ${response.status}`)).trim();
1580
- throw new Error(msg ? `Failed to submit form: ${msg}` : `Failed to submit form (${response.status})`);
1581
- }
1582
1765
  trackComplete();
1583
1766
  setIsComplete(true);
1584
1767
  onSuccess?.(data);
@@ -1587,12 +1770,14 @@ function FormClient({
1587
1770
  }
1588
1771
  } catch (error) {
1589
1772
  console.error("[Forms] Submission error:", error);
1773
+ setSubmitError("Something went wrong sending your submission. Please try again.");
1590
1774
  onError?.(error);
1591
1775
  } finally {
1776
+ isSubmittingRef.current = false;
1592
1777
  setIsSubmitting(false);
1593
1778
  }
1594
- }, [config2, honeypotFieldName, honeypotValue, values, validateStep, trackComplete, onSuccess, onError]);
1595
- const progress = react.useMemo(() => {
1779
+ }, [config2, honeypotValue, validateAll, buildVisibleValues, trackComplete, onSuccess, onError]);
1780
+ const progress = React2.useMemo(() => {
1596
1781
  return Math.round(step / config2.total_steps * 100);
1597
1782
  }, [step, config2.total_steps]);
1598
1783
  const renderProps = {
@@ -1689,6 +1874,15 @@ function FormClient({
1689
1874
  step > 1 && /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "sk-form__btn sk-form__btn--back", onClick: prevStep, children: "Back" }),
1690
1875
  /* @__PURE__ */ jsxRuntime.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 })
1691
1876
  ] }),
1877
+ submitError && /* @__PURE__ */ jsxRuntime.jsx(
1878
+ "p",
1879
+ {
1880
+ className: "sk-form__submit-error",
1881
+ role: "alert",
1882
+ style: { color: "var(--sk-error, #ef4444)", fontSize: 14, marginTop: 12 },
1883
+ children: submitError
1884
+ }
1885
+ ),
1692
1886
  config2.honeypot_enabled && /* @__PURE__ */ jsxRuntime.jsx(
1693
1887
  "input",
1694
1888
  {
@@ -1715,16 +1909,6 @@ function FormClient({
1715
1909
  }
1716
1910
  );
1717
1911
  }
1718
- function getUTMParams2() {
1719
- if (typeof window === "undefined") return {};
1720
- const params = new URLSearchParams(window.location.search);
1721
- const utmParams = {};
1722
- for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
1723
- const value = params.get(key);
1724
- if (value) utmParams[key] = value;
1725
- }
1726
- return utmParams;
1727
- }
1728
1912
  function ManagedForm({
1729
1913
  formId,
1730
1914
  projectId,