@sonordev/site-kit 2.6.1 → 2.7.0
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/{chunk-HSSKX77P.mjs → chunk-FN7ADGWH.mjs} +729 -5
- package/dist/chunk-FN7ADGWH.mjs.map +1 -0
- package/dist/{chunk-D5ES7H7E.js → chunk-KCA5OLS4.js} +729 -4
- package/dist/chunk-KCA5OLS4.js.map +1 -0
- package/dist/commerce/index.d.mts +1 -1
- package/dist/commerce/index.d.ts +1 -1
- package/dist/commerce/index.js +49 -45
- package/dist/commerce/index.mjs +1 -1
- package/dist/forms/index.d.mts +2 -0
- package/dist/forms/index.d.ts +2 -0
- package/dist/forms/index.js +395 -211
- package/dist/forms/index.js.map +1 -1
- package/dist/forms/index.mjs +329 -149
- package/dist/forms/index.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +29 -25
- package/dist/index.mjs +1 -1
- package/dist/{useEventModal-DvqBRau-.d.mts → useEventModal-4W_y1SYY.d.mts} +36 -3
- package/dist/{useEventModal-C4Dr4Psb.d.ts → useEventModal-CJLCmH5Y.d.ts} +36 -3
- package/package.json +1 -1
- package/dist/chunk-D5ES7H7E.js.map +0 -1
- package/dist/chunk-HSSKX77P.mjs.map +0 -1
package/dist/forms/index.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 = () =>
|
|
540
|
-
script.onerror = () =>
|
|
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/
|
|
559
|
-
var
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
|
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
|
|
725
|
-
|
|
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 (
|
|
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
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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,
|
|
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 ||
|
|
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
|
-
|
|
1458
|
+
FileUploadField,
|
|
1351
1459
|
{
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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 ||
|
|
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
|
|
1478
|
-
|
|
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 (
|
|
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
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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,
|
|
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,
|