@businessdash/sdk 0.9.60
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/LICENSE +21 -0
- package/README.md +899 -0
- package/dist/adapters-revalidate.d.ts +44 -0
- package/dist/adapters-revalidate.d.ts.map +1 -0
- package/dist/adapters-revalidate.js +68 -0
- package/dist/adapters-revalidate.js.map +1 -0
- package/dist/alpha.d.ts +21 -0
- package/dist/alpha.d.ts.map +1 -0
- package/dist/alpha.js +21 -0
- package/dist/alpha.js.map +1 -0
- package/dist/analytics-core.d.ts +34 -0
- package/dist/analytics-core.d.ts.map +1 -0
- package/dist/analytics-core.js +138 -0
- package/dist/analytics-core.js.map +1 -0
- package/dist/analytics-server.d.ts +54 -0
- package/dist/analytics-server.d.ts.map +1 -0
- package/dist/analytics-server.js +78 -0
- package/dist/analytics-server.js.map +1 -0
- package/dist/attribution-logo.d.ts +2 -0
- package/dist/attribution-logo.d.ts.map +1 -0
- package/dist/attribution-logo.js +5 -0
- package/dist/attribution-logo.js.map +1 -0
- package/dist/auth-handler.d.ts +92 -0
- package/dist/auth-handler.d.ts.map +1 -0
- package/dist/auth-handler.js +277 -0
- package/dist/auth-handler.js.map +1 -0
- package/dist/biab-forms.css +484 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1824 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1681 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2845 -0
- package/dist/client.js.map +1 -0
- package/dist/collections.d.ts +13 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +13 -0
- package/dist/collections.js.map +1 -0
- package/dist/contracts.d.ts +14609 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +2428 -0
- package/dist/contracts.js.map +1 -0
- package/dist/data-model-schema.d.ts +962 -0
- package/dist/data-model-schema.d.ts.map +1 -0
- package/dist/data-model-schema.js +2295 -0
- package/dist/data-model-schema.js.map +1 -0
- package/dist/distribution.d.ts +38 -0
- package/dist/distribution.d.ts.map +1 -0
- package/dist/distribution.js +61 -0
- package/dist/distribution.js.map +1 -0
- package/dist/element.d.ts +121 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +276 -0
- package/dist/element.js.map +1 -0
- package/dist/enums.d.ts +25 -0
- package/dist/enums.d.ts.map +1 -0
- package/dist/enums.js +38 -0
- package/dist/enums.js.map +1 -0
- package/dist/errors.d.ts +195 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +188 -0
- package/dist/errors.js.map +1 -0
- package/dist/forms-core/autocomplete.d.ts +37 -0
- package/dist/forms-core/autocomplete.d.ts.map +1 -0
- package/dist/forms-core/autocomplete.js +82 -0
- package/dist/forms-core/autocomplete.js.map +1 -0
- package/dist/forms-core/business-function.d.ts +61 -0
- package/dist/forms-core/business-function.d.ts.map +1 -0
- package/dist/forms-core/business-function.js +149 -0
- package/dist/forms-core/business-function.js.map +1 -0
- package/dist/forms-core/calculation.d.ts +72 -0
- package/dist/forms-core/calculation.d.ts.map +1 -0
- package/dist/forms-core/calculation.js +103 -0
- package/dist/forms-core/calculation.js.map +1 -0
- package/dist/forms-core/controller.d.ts +249 -0
- package/dist/forms-core/controller.d.ts.map +1 -0
- package/dist/forms-core/controller.js +590 -0
- package/dist/forms-core/controller.js.map +1 -0
- package/dist/forms-core/elements.d.ts +44 -0
- package/dist/forms-core/elements.d.ts.map +1 -0
- package/dist/forms-core/elements.js +68 -0
- package/dist/forms-core/elements.js.map +1 -0
- package/dist/forms-core/email-domains.d.ts +10 -0
- package/dist/forms-core/email-domains.d.ts.map +1 -0
- package/dist/forms-core/email-domains.js +69 -0
- package/dist/forms-core/email-domains.js.map +1 -0
- package/dist/forms-core/file-rules.d.ts +51 -0
- package/dist/forms-core/file-rules.d.ts.map +1 -0
- package/dist/forms-core/file-rules.js +105 -0
- package/dist/forms-core/file-rules.js.map +1 -0
- package/dist/forms-core/formula.d.ts +114 -0
- package/dist/forms-core/formula.d.ts.map +1 -0
- package/dist/forms-core/formula.js +1170 -0
- package/dist/forms-core/formula.js.map +1 -0
- package/dist/forms-core/index.d.ts +24 -0
- package/dist/forms-core/index.d.ts.map +1 -0
- package/dist/forms-core/index.js +28 -0
- package/dist/forms-core/index.js.map +1 -0
- package/dist/forms-core/units.d.ts +49 -0
- package/dist/forms-core/units.d.ts.map +1 -0
- package/dist/forms-core/units.js +255 -0
- package/dist/forms-core/units.js.map +1 -0
- package/dist/forms-core/us-states.d.ts +14 -0
- package/dist/forms-core/us-states.d.ts.map +1 -0
- package/dist/forms-core/us-states.js +66 -0
- package/dist/forms-core/us-states.js.map +1 -0
- package/dist/forms-core/validation.d.ts +266 -0
- package/dist/forms-core/validation.d.ts.map +1 -0
- package/dist/forms-core/validation.js +850 -0
- package/dist/forms-core/validation.js.map +1 -0
- package/dist/forms.d.ts +19 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +29 -0
- package/dist/forms.js.map +1 -0
- package/dist/gallery-types.d.ts +32 -0
- package/dist/gallery-types.d.ts.map +1 -0
- package/dist/gallery-types.js +2 -0
- package/dist/gallery-types.js.map +1 -0
- package/dist/graphql.d.ts +97 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +212 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.cjs +19448 -0
- package/dist/index.d.cts +19 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/marketing-schema.d.ts +216 -0
- package/dist/marketing-schema.d.ts.map +1 -0
- package/dist/marketing-schema.js +200 -0
- package/dist/marketing-schema.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +87 -0
- package/dist/mcp.js.map +1 -0
- package/dist/native.d.ts +45 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +43 -0
- package/dist/native.js.map +1 -0
- package/dist/next-revalidate.d.ts +43 -0
- package/dist/next-revalidate.d.ts.map +1 -0
- package/dist/next-revalidate.js +84 -0
- package/dist/next-revalidate.js.map +1 -0
- package/dist/parallel-pages.d.ts +102 -0
- package/dist/parallel-pages.d.ts.map +1 -0
- package/dist/parallel-pages.js +108 -0
- package/dist/parallel-pages.js.map +1 -0
- package/dist/payment-icons.d.ts +66 -0
- package/dist/payment-icons.d.ts.map +1 -0
- package/dist/payment-icons.js +160 -0
- package/dist/payment-icons.js.map +1 -0
- package/dist/proxy.d.ts +67 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +97 -0
- package/dist/proxy.js.map +1 -0
- package/dist/react-analytics.d.ts +39 -0
- package/dist/react-analytics.d.ts.map +1 -0
- package/dist/react-analytics.js +59 -0
- package/dist/react-analytics.js.map +1 -0
- package/dist/react-attribution.d.ts +99 -0
- package/dist/react-attribution.d.ts.map +1 -0
- package/dist/react-attribution.js +52 -0
- package/dist/react-attribution.js.map +1 -0
- package/dist/react-marketing.d.ts +181 -0
- package/dist/react-marketing.d.ts.map +1 -0
- package/dist/react-marketing.js +215 -0
- package/dist/react-marketing.js.map +1 -0
- package/dist/react.d.ts +727 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2418 -0
- package/dist/react.js.map +1 -0
- package/dist/reserved-paths.d.ts +24 -0
- package/dist/reserved-paths.d.ts.map +1 -0
- package/dist/reserved-paths.js +60 -0
- package/dist/reserved-paths.js.map +1 -0
- package/dist/revalidate-verify.d.ts +30 -0
- package/dist/revalidate-verify.d.ts.map +1 -0
- package/dist/revalidate-verify.js +74 -0
- package/dist/revalidate-verify.js.map +1 -0
- package/dist/sdk.d.ts +678 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +801 -0
- package/dist/sdk.js.map +1 -0
- package/dist/seo.d.ts +218 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +309 -0
- package/dist/seo.js.map +1 -0
- package/dist/socials.d.ts +47 -0
- package/dist/socials.d.ts.map +1 -0
- package/dist/socials.js +172 -0
- package/dist/socials.js.map +1 -0
- package/dist/static.d.ts +14 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +14 -0
- package/dist/static.js.map +1 -0
- package/dist/url-pattern.d.ts +47 -0
- package/dist/url-pattern.d.ts.map +1 -0
- package/dist/url-pattern.js +124 -0
- package/dist/url-pattern.js.map +1 -0
- package/dist/vanilla.d.ts +77 -0
- package/dist/vanilla.d.ts.map +1 -0
- package/dist/vanilla.js +903 -0
- package/dist/vanilla.js.map +1 -0
- package/openapi.json +13779 -0
- package/package.json +204 -0
- package/src/adapters-revalidate.ts +88 -0
- package/src/alpha.ts +89 -0
- package/src/analytics-core.ts +175 -0
- package/src/analytics-server.ts +99 -0
- package/src/angular/biab-form.component.ts +1443 -0
- package/src/angular/index.ts +26 -0
- package/src/attribution-logo.ts +5 -0
- package/src/auth-handler.ts +352 -0
- package/src/cli.ts +2517 -0
- package/src/client.ts +3578 -0
- package/src/collections.ts +101 -0
- package/src/contracts.ts +3390 -0
- package/src/data-model-schema.ts +4042 -0
- package/src/distribution.ts +73 -0
- package/src/element.ts +321 -0
- package/src/enums.ts +65 -0
- package/src/errors.ts +338 -0
- package/src/forms-core/autocomplete.ts +97 -0
- package/src/forms-core/business-function.ts +209 -0
- package/src/forms-core/calculation.ts +167 -0
- package/src/forms-core/controller.ts +922 -0
- package/src/forms-core/elements.ts +86 -0
- package/src/forms-core/email-domains.ts +68 -0
- package/src/forms-core/file-rules.ts +123 -0
- package/src/forms-core/formula.ts +1339 -0
- package/src/forms-core/index.ts +122 -0
- package/src/forms-core/units.ts +308 -0
- package/src/forms-core/us-states.ts +68 -0
- package/src/forms-core/validation.ts +1037 -0
- package/src/forms.ts +63 -0
- package/src/gallery-types.ts +31 -0
- package/src/graphql.ts +295 -0
- package/src/index.ts +505 -0
- package/src/marketing-schema.ts +395 -0
- package/src/mcp.ts +108 -0
- package/src/native.ts +73 -0
- package/src/next-revalidate.ts +124 -0
- package/src/parallel-pages.ts +192 -0
- package/src/payment-icons.ts +205 -0
- package/src/proxy.ts +173 -0
- package/src/qwik.tsx +1234 -0
- package/src/react-analytics.tsx +76 -0
- package/src/react-attribution.tsx +192 -0
- package/src/react-marketing.tsx +338 -0
- package/src/react.tsx +5495 -0
- package/src/reserved-paths.ts +65 -0
- package/src/revalidate-verify.ts +98 -0
- package/src/sdk.ts +1671 -0
- package/src/seo.ts +488 -0
- package/src/socials.ts +230 -0
- package/src/solid.tsx +1466 -0
- package/src/static.ts +21 -0
- package/src/styles/biab-forms.css +484 -0
- package/src/svelte/BiabForm.svelte +1069 -0
- package/src/svelte/context.ts +57 -0
- package/src/svelte/index.ts +79 -0
- package/src/url-pattern.ts +153 -0
- package/src/vanilla.ts +1290 -0
- package/src/vue/BiabForm.vue +1538 -0
- package/src/vue/index.ts +119 -0
package/dist/react.js
ADDED
|
@@ -0,0 +1,2418 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
3
|
+
import { BiabDevClient } from "./client.js";
|
|
4
|
+
import { buildAcceptAttr, concurrentRevealCount, createFormController, evalCondition, fetchIconNames, filterIconNames, flattenValueFields, formatCalcValue, iconUrl, isPendingFileValue, resolveFieldAutocomplete, shouldSearchOptions, validateFile, } from "./forms-core/index.js";
|
|
5
|
+
import { baseUnitFor, convertToBase, formatMeasurement, isMeasurementCategory, unitsForCategory, } from "./forms-core/units.js";
|
|
6
|
+
import { US_STATES } from "./forms-core/us-states.js";
|
|
7
|
+
import { createBiabClient } from "./sdk.js";
|
|
8
|
+
import { resolveSocialProfiles } from "./socials.js";
|
|
9
|
+
function useEmbedSession(loader, options) {
|
|
10
|
+
const { apiKey, baseUrl, refreshLeadMs = 30_000 } = options;
|
|
11
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
12
|
+
const [state, setState] = useState({ status: "idle" });
|
|
13
|
+
// `loader` is recreated per surface; depending on it would refetch on every
|
|
14
|
+
// render, so it's intentionally omitted from the deps below.
|
|
15
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: loader omitted by design
|
|
16
|
+
const refresh = useCallback(async () => {
|
|
17
|
+
setState({ status: "loading" });
|
|
18
|
+
try {
|
|
19
|
+
const session = await loader(client);
|
|
20
|
+
setState({ status: "ready", session });
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
setState({
|
|
24
|
+
status: "error",
|
|
25
|
+
error: error instanceof Error ? error : new Error("Unknown session error"),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}, [client]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
void refresh();
|
|
31
|
+
}, [refresh]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (state.status !== "ready")
|
|
34
|
+
return;
|
|
35
|
+
const expiresAt = Date.parse(state.session.expiresAt);
|
|
36
|
+
if (Number.isNaN(expiresAt))
|
|
37
|
+
return;
|
|
38
|
+
const delay = Math.max(1_000, expiresAt - Date.now() - refreshLeadMs);
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
void refresh();
|
|
41
|
+
}, delay);
|
|
42
|
+
return () => clearTimeout(timer);
|
|
43
|
+
}, [state, refresh, refreshLeadMs]);
|
|
44
|
+
return { client, state, refresh };
|
|
45
|
+
}
|
|
46
|
+
function useEmbedMessages(iframeRef, handler) {
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const onMessage = (e) => {
|
|
49
|
+
if (!e.data || typeof e.data !== "object")
|
|
50
|
+
return;
|
|
51
|
+
if (e.data.kind !== "biab")
|
|
52
|
+
return;
|
|
53
|
+
if (iframeRef.current &&
|
|
54
|
+
e.source !== null &&
|
|
55
|
+
e.source !== iframeRef.current.contentWindow)
|
|
56
|
+
return;
|
|
57
|
+
const { kind, ...rest } = e.data;
|
|
58
|
+
void kind;
|
|
59
|
+
handler(rest);
|
|
60
|
+
};
|
|
61
|
+
window.addEventListener("message", onMessage);
|
|
62
|
+
return () => window.removeEventListener("message", onMessage);
|
|
63
|
+
}, [handler, iframeRef]);
|
|
64
|
+
}
|
|
65
|
+
// Asserted-compat with the underlying DashboardSessionResponse so the public
|
|
66
|
+
// `useDashboardSession` shape doesn't churn for existing callers.
|
|
67
|
+
const _dashboardCompat = true;
|
|
68
|
+
void _dashboardCompat;
|
|
69
|
+
const DashboardContext = createContext(null);
|
|
70
|
+
export function DashboardProvider({ apiKey, baseUrl, children, refreshLeadMs, }) {
|
|
71
|
+
if (!apiKey) {
|
|
72
|
+
throw new Error("<DashboardProvider> requires an `apiKey`.");
|
|
73
|
+
}
|
|
74
|
+
if (!baseUrl) {
|
|
75
|
+
throw new Error("<DashboardProvider> requires a `baseUrl`.");
|
|
76
|
+
}
|
|
77
|
+
const { state, refresh } = useEmbedSession((client) => client.dashboard.createSession(), { apiKey, baseUrl, refreshLeadMs });
|
|
78
|
+
const value = useMemo(() => ({ state, refresh }), [state, refresh]);
|
|
79
|
+
return (_jsx(DashboardContext.Provider, { value: value, children: children }));
|
|
80
|
+
}
|
|
81
|
+
export function useDashboardSession() {
|
|
82
|
+
const ctx = useContext(DashboardContext);
|
|
83
|
+
if (!ctx) {
|
|
84
|
+
throw new Error("useDashboardSession must be used inside <DashboardProvider>.");
|
|
85
|
+
}
|
|
86
|
+
return ctx;
|
|
87
|
+
}
|
|
88
|
+
const defaultIframeStyle = {
|
|
89
|
+
width: "100%",
|
|
90
|
+
height: "100%",
|
|
91
|
+
minHeight: "600px",
|
|
92
|
+
border: "0",
|
|
93
|
+
display: "block",
|
|
94
|
+
};
|
|
95
|
+
export function Dashboard({ className, style, title = "Business In A Box Dashboard", loadingFallback, errorFallback, } = {}) {
|
|
96
|
+
const { state, refresh } = useDashboardSession();
|
|
97
|
+
if (state.status === "error") {
|
|
98
|
+
if (errorFallback)
|
|
99
|
+
return _jsx(_Fragment, { children: errorFallback(state.error, () => void refresh()) });
|
|
100
|
+
return (_jsxs("div", { className: className, style: { padding: 16, ...style }, children: ["Dashboard unavailable: ", state.error.message] }));
|
|
101
|
+
}
|
|
102
|
+
if (state.status !== "ready") {
|
|
103
|
+
return (_jsx(_Fragment, { children: loadingFallback ?? (_jsx("div", { className: className, style: { padding: 16, ...style }, children: "Loading dashboard\u2026" })) }));
|
|
104
|
+
}
|
|
105
|
+
return (_jsx("iframe", { className: className, sandbox: "allow-scripts allow-forms allow-same-origin allow-popups", src: state.session.url, style: { ...defaultIframeStyle, ...style }, title: title }));
|
|
106
|
+
}
|
|
107
|
+
function EmbedIframe({ state, refresh, title, className, style, loadingFallback, errorFallback, onEvent, extraSandbox, }) {
|
|
108
|
+
const iframeRef = useRef(null);
|
|
109
|
+
const onEventRef = useRef(onEvent);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
onEventRef.current = onEvent;
|
|
112
|
+
}, [onEvent]);
|
|
113
|
+
useEmbedMessages(iframeRef, (event) => {
|
|
114
|
+
onEventRef.current?.(event);
|
|
115
|
+
});
|
|
116
|
+
if (state.status === "error") {
|
|
117
|
+
if (errorFallback)
|
|
118
|
+
return _jsx(_Fragment, { children: errorFallback(state.error, () => void refresh()) });
|
|
119
|
+
return (_jsxs("div", { className: className, style: { padding: 16, ...style }, children: ["Embed unavailable: ", state.error.message] }));
|
|
120
|
+
}
|
|
121
|
+
if (state.status !== "ready") {
|
|
122
|
+
return (_jsx(_Fragment, { children: loadingFallback ?? (_jsx("div", { className: className, style: { padding: 16, ...style }, children: "Loading\u2026" })) }));
|
|
123
|
+
}
|
|
124
|
+
const sandbox = `allow-scripts allow-forms allow-same-origin allow-popups${extraSandbox ? ` ${extraSandbox}` : ""}`;
|
|
125
|
+
return (_jsx("iframe", { className: className, ref: iframeRef, sandbox: sandbox, src: state.session.url, style: { ...defaultIframeStyle, ...style }, title: title }));
|
|
126
|
+
}
|
|
127
|
+
export function Storefront({ apiKey, baseUrl, productId, className, style, title = "Storefront", loadingFallback, errorFallback, onEvent, onCartChange, refreshLeadMs, }) {
|
|
128
|
+
const input = useMemo(() => (productId ? { productId } : undefined), [productId]);
|
|
129
|
+
const { state, refresh } = useEmbedSession((client) => client.storefront.createSession(input), { apiKey, baseUrl, refreshLeadMs });
|
|
130
|
+
const handleEvent = useCallback((event) => {
|
|
131
|
+
onEvent?.(event);
|
|
132
|
+
if (event.type === "biab:cart:update") {
|
|
133
|
+
onCartChange?.(event.count, event.subtotalCents);
|
|
134
|
+
}
|
|
135
|
+
}, [onEvent, onCartChange]);
|
|
136
|
+
return (_jsx(EmbedIframe, { className: className, errorFallback: errorFallback, loadingFallback: loadingFallback, onEvent: handleEvent, refresh: refresh, state: state, style: style, title: title }));
|
|
137
|
+
}
|
|
138
|
+
export function Cart({ apiKey, baseUrl, className, style, title = "Cart", loadingFallback, errorFallback, onEvent, onCartChange, refreshLeadMs, }) {
|
|
139
|
+
const { state, refresh } = useEmbedSession((client) => client.cart.createSession(), { apiKey, baseUrl, refreshLeadMs });
|
|
140
|
+
const handleEvent = useCallback((event) => {
|
|
141
|
+
onEvent?.(event);
|
|
142
|
+
if (event.type === "biab:cart:update") {
|
|
143
|
+
onCartChange?.(event.count, event.subtotalCents);
|
|
144
|
+
}
|
|
145
|
+
}, [onEvent, onCartChange]);
|
|
146
|
+
return (_jsx(EmbedIframe, { className: className, errorFallback: errorFallback, loadingFallback: loadingFallback, onEvent: handleEvent, refresh: refresh, state: state, style: style, title: title }));
|
|
147
|
+
}
|
|
148
|
+
export function Checkout(props) {
|
|
149
|
+
const { apiKey, baseUrl, cart, mode = "redirect", className, style, title = "Checkout", children, loadingFallback, errorFallback, onEvent, onSuccess, onCancel, onError, } = props;
|
|
150
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
151
|
+
const [state, setState] = useState({
|
|
152
|
+
status: "idle",
|
|
153
|
+
});
|
|
154
|
+
const start = useCallback(async () => {
|
|
155
|
+
setState({ status: "loading" });
|
|
156
|
+
try {
|
|
157
|
+
const session = await client.checkout.createSession(cart);
|
|
158
|
+
setState({ status: "ready", session });
|
|
159
|
+
if (mode === "redirect") {
|
|
160
|
+
window.location.href = session.stripeUrl;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const err = error instanceof Error ? error : new Error("Checkout failed");
|
|
165
|
+
setState({ status: "error", error: err });
|
|
166
|
+
onError?.(err);
|
|
167
|
+
}
|
|
168
|
+
}, [client, cart, mode, onError]);
|
|
169
|
+
const handleEvent = useCallback((event) => {
|
|
170
|
+
onEvent?.(event);
|
|
171
|
+
if (event.type === "biab:checkout:success")
|
|
172
|
+
onSuccess?.(event.sessionId);
|
|
173
|
+
else if (event.type === "biab:checkout:cancel")
|
|
174
|
+
onCancel?.(event.sessionId);
|
|
175
|
+
else if (event.type === "biab:checkout:error")
|
|
176
|
+
onError?.(new Error(event.message));
|
|
177
|
+
}, [onEvent, onSuccess, onCancel, onError]);
|
|
178
|
+
// Lazily create the session once it's idle (iframe mode). Declared BEFORE the
|
|
179
|
+
// redirect-mode early return so the hook order stays stable across renders
|
|
180
|
+
// (rules of hooks); the mode guard keeps redirect mode from auto-starting.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (mode !== "redirect" && state.status === "idle")
|
|
183
|
+
void start();
|
|
184
|
+
}, [mode, state.status, start]);
|
|
185
|
+
if (mode === "redirect") {
|
|
186
|
+
return (_jsx("button", { className: className, disabled: state.status === "loading", onClick: () => void start(), style: style, type: "button", children: children ?? (state.status === "loading" ? "Loading…" : "Checkout") }));
|
|
187
|
+
}
|
|
188
|
+
return (_jsx(EmbedIframe, { className: className, errorFallback: errorFallback, extraSandbox: "allow-top-navigation", loadingFallback: loadingFallback, onEvent: handleEvent, refresh: start, state: state, style: style, title: title }));
|
|
189
|
+
}
|
|
190
|
+
export function CheckoutResult(props) {
|
|
191
|
+
const { apiKey, baseUrl, orgSlug, sessionIdParam = "session_id", render, } = props;
|
|
192
|
+
const [state, setState] = useState({ status: "loading" });
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const url = new URL(window.location.href);
|
|
195
|
+
const sessionId = url.searchParams.get(sessionIdParam);
|
|
196
|
+
if (!sessionId) {
|
|
197
|
+
setState({ status: "error", error: new Error("Missing session id.") });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const client = new BiabDevClient({ apiKey, baseUrl });
|
|
201
|
+
// We hit the *public* tRPC endpoint via fetch — no API key required.
|
|
202
|
+
const trpcUrl = baseUrl.replace(/\/api\/package\/v1\/?$/, "");
|
|
203
|
+
const requestUrl = `${trpcUrl}/api/trpc/store.checkoutSessionStatus?input=${encodeURIComponent(JSON.stringify({ json: { orgSlug, sessionId } }))}`;
|
|
204
|
+
// Defensive: reject misconfigured baseUrl that resolves to a non-http(s)
|
|
205
|
+
// scheme (e.g. `javascript:` slipping in through SDK config).
|
|
206
|
+
const parsed = (() => {
|
|
207
|
+
try {
|
|
208
|
+
return new URL(requestUrl);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
if (!parsed ||
|
|
215
|
+
(parsed.protocol !== "https:" && parsed.protocol !== "http:")) {
|
|
216
|
+
setState({
|
|
217
|
+
status: "error",
|
|
218
|
+
error: new Error(`Invalid baseUrl: ${baseUrl}`),
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
fetch(parsed.toString())
|
|
223
|
+
.then((r) => r.json())
|
|
224
|
+
.then((data) => {
|
|
225
|
+
const result = data?.result?.data?.json ?? data?.result?.data;
|
|
226
|
+
if (!result)
|
|
227
|
+
throw new Error("Empty checkout-status response.");
|
|
228
|
+
setState({
|
|
229
|
+
status: result.paymentStatus === "paid" ? "paid" : "unpaid",
|
|
230
|
+
amountTotalCents: result.amountTotalCents,
|
|
231
|
+
currency: result.currency,
|
|
232
|
+
customerEmail: result.customerEmail,
|
|
233
|
+
});
|
|
234
|
+
})
|
|
235
|
+
.catch((err) => {
|
|
236
|
+
setState({
|
|
237
|
+
status: "error",
|
|
238
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
void client; // silence unused-warning if fetch path changes later
|
|
242
|
+
}, [apiKey, baseUrl, orgSlug, sessionIdParam]);
|
|
243
|
+
if (render)
|
|
244
|
+
return _jsx(_Fragment, { children: render(state) });
|
|
245
|
+
if (state.status === "loading")
|
|
246
|
+
return _jsx("p", { children: "Loading order\u2026" });
|
|
247
|
+
if (state.status === "error")
|
|
248
|
+
return _jsxs("p", { children: ["Couldn't load order: ", state.error?.message] });
|
|
249
|
+
if (state.status === "paid")
|
|
250
|
+
return (_jsxs("p", { children: ["Thank you! Receipt sent to ", state.customerEmail ?? "your email", "."] }));
|
|
251
|
+
return _jsx("p", { children: "Payment status: pending. We'll email you when it finalizes." });
|
|
252
|
+
}
|
|
253
|
+
export function Chatbot({ apiKey, baseUrl, className, style, title = "Assistant", loadingFallback, errorFallback, onEvent, onAssistantMessage, refreshLeadMs, }) {
|
|
254
|
+
const { state, refresh } = useEmbedSession((client) => client.chatbot.createSession(), { apiKey, baseUrl, refreshLeadMs });
|
|
255
|
+
const handleEvent = useCallback((event) => {
|
|
256
|
+
onEvent?.(event);
|
|
257
|
+
if (event.type === "biab:chatbot:assistant-message") {
|
|
258
|
+
onAssistantMessage?.(event.content, event.model);
|
|
259
|
+
}
|
|
260
|
+
}, [onEvent, onAssistantMessage]);
|
|
261
|
+
return (_jsx(EmbedIframe, { className: className, errorFallback: errorFallback, loadingFallback: loadingFallback, onEvent: handleEvent, refresh: refresh, state: state, style: style, title: title }));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Headless chat hook — for hosts that want to render their own chat UI but
|
|
265
|
+
* still drive every turn through the org's persisted chatbot config.
|
|
266
|
+
*
|
|
267
|
+
* Beyond `messages` and `send`, the hook tracks the latest `uiActions` (e.g.
|
|
268
|
+
* `show_form` — call {@link useChatbot}.getForm to fetch the definition and
|
|
269
|
+
* {@link useChatbot}.submitForm to POST a filled form back), and the latest
|
|
270
|
+
* `availability` snapshot (front-desk presence + wait-time hint) so the
|
|
271
|
+
* consumer can show or hide its "Talk to a person" CTA.
|
|
272
|
+
*/
|
|
273
|
+
export function useChatbot(options) {
|
|
274
|
+
const { apiKey, baseUrl } = options;
|
|
275
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
276
|
+
const [messages, setMessages] = useState([]);
|
|
277
|
+
const [isPending, setIsPending] = useState(false);
|
|
278
|
+
const [error, setError] = useState(null);
|
|
279
|
+
const [latestUiActions, setLatestUiActions] = useState([]);
|
|
280
|
+
const [availability, setAvailability] = useState(null);
|
|
281
|
+
const send = useCallback(async (content) => {
|
|
282
|
+
const trimmed = content.trim();
|
|
283
|
+
if (!trimmed)
|
|
284
|
+
return null;
|
|
285
|
+
setError(null);
|
|
286
|
+
setIsPending(true);
|
|
287
|
+
const next = [
|
|
288
|
+
...messages,
|
|
289
|
+
{ role: "user", content: trimmed },
|
|
290
|
+
];
|
|
291
|
+
setMessages(next);
|
|
292
|
+
try {
|
|
293
|
+
const input = { messages: next };
|
|
294
|
+
const reply = await client.chatbot.chat(input);
|
|
295
|
+
setMessages((prev) => [
|
|
296
|
+
...prev,
|
|
297
|
+
{ role: "assistant", content: reply.text },
|
|
298
|
+
]);
|
|
299
|
+
setLatestUiActions(reply.uiActions ?? []);
|
|
300
|
+
if (reply.availability)
|
|
301
|
+
setAvailability(reply.availability);
|
|
302
|
+
return reply;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
const e = err instanceof Error ? err : new Error("Chat failed");
|
|
306
|
+
setError(e);
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
setIsPending(false);
|
|
311
|
+
}
|
|
312
|
+
}, [client, messages]);
|
|
313
|
+
const reset = useCallback(() => {
|
|
314
|
+
setMessages([]);
|
|
315
|
+
setError(null);
|
|
316
|
+
setLatestUiActions([]);
|
|
317
|
+
}, []);
|
|
318
|
+
const getConfig = useCallback(async () => client.chatbot.getConfig(), [client]);
|
|
319
|
+
const refreshAvailability = useCallback(async () => {
|
|
320
|
+
try {
|
|
321
|
+
const next = await client.chatbot.getAvailability();
|
|
322
|
+
setAvailability(next);
|
|
323
|
+
return next;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}, [client]);
|
|
329
|
+
const getForm = useCallback(async (slug) => {
|
|
330
|
+
try {
|
|
331
|
+
// Chat `show_form` flow → the chatbot path (Live + chatbotAccess).
|
|
332
|
+
return await client.chatbot.getChatForm(slug);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}, [client]);
|
|
338
|
+
const submitForm = useCallback(async (slug, input) => {
|
|
339
|
+
try {
|
|
340
|
+
const priorChat = messages.map((m, i) => ({
|
|
341
|
+
role: m.role,
|
|
342
|
+
content: m.content,
|
|
343
|
+
createdAt: i,
|
|
344
|
+
}));
|
|
345
|
+
// Chatbot submit carries the transcript → chatbot path.
|
|
346
|
+
return await client.chatbot.submitChatForm(slug, {
|
|
347
|
+
...input,
|
|
348
|
+
priorChat: input.priorChat ?? priorChat,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
setError(err instanceof Error ? err : new Error("Submit failed"));
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}, [client, messages]);
|
|
356
|
+
const uploadFile = useCallback((args) => client.chatbot.uploadFile(args), [client]);
|
|
357
|
+
return {
|
|
358
|
+
messages,
|
|
359
|
+
send,
|
|
360
|
+
reset,
|
|
361
|
+
isPending,
|
|
362
|
+
error,
|
|
363
|
+
getConfig,
|
|
364
|
+
latestUiActions,
|
|
365
|
+
availability,
|
|
366
|
+
refreshAvailability,
|
|
367
|
+
getForm,
|
|
368
|
+
submitForm,
|
|
369
|
+
uploadFile,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const STATIC_FIELD_TYPES = new Set([
|
|
373
|
+
"title",
|
|
374
|
+
"subtitle",
|
|
375
|
+
"separator",
|
|
376
|
+
"flex_row",
|
|
377
|
+
"input_group",
|
|
378
|
+
]);
|
|
379
|
+
/**
|
|
380
|
+
* Structural-only inline styles. Anything visual (gap, padding, color,
|
|
381
|
+
* border, radius) is left for the consumer's `classNames` slots to set
|
|
382
|
+
* via their own Tailwind / CSS — these inline styles only enforce that
|
|
383
|
+
* the layout actually works (column flex, scrolling message list, etc.)
|
|
384
|
+
* and would otherwise be tedious to require from every consumer.
|
|
385
|
+
*/
|
|
386
|
+
const inlineLayoutStyles = {
|
|
387
|
+
root: {
|
|
388
|
+
display: "flex",
|
|
389
|
+
flexDirection: "column",
|
|
390
|
+
minHeight: 0,
|
|
391
|
+
// `relative` so the jump-to-latest control can absolutely-position itself
|
|
392
|
+
// over the transcript.
|
|
393
|
+
position: "relative",
|
|
394
|
+
overflow: "hidden",
|
|
395
|
+
},
|
|
396
|
+
messageList: {
|
|
397
|
+
flex: 1,
|
|
398
|
+
overflowY: "auto",
|
|
399
|
+
display: "flex",
|
|
400
|
+
flexDirection: "column",
|
|
401
|
+
},
|
|
402
|
+
inputBar: { display: "flex" },
|
|
403
|
+
inputField: { flex: 1 },
|
|
404
|
+
formField: { display: "flex", flexDirection: "column" },
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* Opt-in default visual theme for {@link ChatbotInline}. Every rule is wrapped
|
|
408
|
+
* in `:where(…)` so it has **zero specificity** — any class a consumer passes
|
|
409
|
+
* via `classNames` (or their global CSS) overrides it without `!important`.
|
|
410
|
+
* Scoped to the `[data-biab-chatbot]` root (set only when `unstyled` is false),
|
|
411
|
+
* and targets the per-slot `data-slot` attributes the component stamps on each
|
|
412
|
+
* element. Injected once into `<head>` (id-guarded). This is what makes the
|
|
413
|
+
* inline component a polished drop-in instead of bare divs, while preserving
|
|
414
|
+
* the headless contract for anyone who wants to style it themselves.
|
|
415
|
+
*/
|
|
416
|
+
const CHATBOT_INLINE_STYLE_ID = "biab-chatbot-inline-styles";
|
|
417
|
+
const CHATBOT_INLINE_CSS = `
|
|
418
|
+
:where([data-biab-chatbot]){font-size:14px;color:inherit;background:var(--biab-chatbot-bg,Canvas);border:1px solid color-mix(in srgb,currentColor 12%,transparent);border-radius:16px;}
|
|
419
|
+
:where([data-biab-chatbot] [data-slot="header"]){padding:12px 16px;border-bottom:1px solid color-mix(in srgb,currentColor 10%,transparent);}
|
|
420
|
+
:where([data-biab-chatbot] [data-slot="header-title"]){font-weight:600;}
|
|
421
|
+
:where([data-biab-chatbot] [data-slot="header-subtitle"]){font-size:12px;opacity:.6;margin-top:2px;}
|
|
422
|
+
:where([data-biab-chatbot] [data-slot="presence-strip"]){display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;font-size:12px;border-bottom:1px solid color-mix(in srgb,currentColor 8%,transparent);opacity:.85;}
|
|
423
|
+
:where([data-biab-chatbot] [data-slot="message-list"]){padding:16px;gap:12px;}
|
|
424
|
+
:where([data-biab-chatbot] [data-slot="message-row"]){max-width:85%;width:fit-content;padding:8px 12px;border-radius:16px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;box-shadow:0 1px 2px color-mix(in srgb,currentColor 8%,transparent);}
|
|
425
|
+
:where([data-biab-chatbot] [data-slot="message-row"][data-role="user"]){align-self:flex-end;background:var(--biab-chatbot-accent,#0d9488);color:#fff;border-bottom-right-radius:4px;}
|
|
426
|
+
:where([data-biab-chatbot] [data-slot="message-row"][data-role="assistant"]){align-self:flex-start;background:color-mix(in srgb,currentColor 8%,transparent);border-bottom-left-radius:4px;}
|
|
427
|
+
:where([data-biab-chatbot] [data-slot="message-pending"]){align-self:flex-start;width:fit-content;padding:8px 12px;background-image:linear-gradient(90deg,currentColor 35%,color-mix(in srgb,currentColor 25%,transparent) 50%,currentColor 65%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;animation:biab-chatbot-shimmer 2s linear infinite;}
|
|
428
|
+
:where([data-biab-chatbot] [data-slot="error-text"]){align-self:center;font-size:12px;color:#dc2626;}
|
|
429
|
+
:where([data-biab-chatbot] [data-slot="input-bar"]){gap:8px;padding:12px;border-top:1px solid color-mix(in srgb,currentColor 10%,transparent);}
|
|
430
|
+
:where([data-biab-chatbot] [data-slot="input-field"]){padding:8px 12px;border-radius:10px;border:1px solid color-mix(in srgb,currentColor 15%,transparent);background:transparent;color:inherit;font:inherit;outline:none;}
|
|
431
|
+
:where([data-biab-chatbot] [data-slot="input-field"]:focus-visible){border-color:var(--biab-chatbot-accent,#0d9488);}
|
|
432
|
+
:where([data-biab-chatbot] [data-slot="send-button"]){padding:8px 14px;border-radius:10px;border:none;background:var(--biab-chatbot-accent,#0d9488);color:#fff;font-weight:500;cursor:pointer;}
|
|
433
|
+
:where([data-biab-chatbot] [data-slot="send-button"]:disabled){opacity:.5;cursor:default;}
|
|
434
|
+
:where([data-biab-chatbot] [data-slot="upload-button"]){display:inline-flex;align-items:center;justify-content:center;padding:0 6px;border:none;background:transparent;color:inherit;font-size:18px;line-height:1;cursor:pointer;opacity:.7;}
|
|
435
|
+
:where([data-biab-chatbot] [data-slot="upload-button"]:hover){opacity:1;}
|
|
436
|
+
:where([data-biab-chatbot] [data-slot="upload-button"]:disabled){opacity:.4;cursor:default;}
|
|
437
|
+
:where([data-biab-chatbot] [data-slot="message-file"]){align-self:flex-end;max-width:85%;padding:8px 12px;border-radius:16px;border-bottom-right-radius:4px;background:var(--biab-chatbot-accent,#0d9488);color:#fff;text-decoration:none;font-size:13px;word-break:break-all;}
|
|
438
|
+
:where([data-biab-chatbot] [data-slot="message-image"]){align-self:flex-end;max-width:85%;border-radius:12px;overflow:hidden;}
|
|
439
|
+
:where([data-biab-chatbot] [data-slot="jump-latest"]){position:absolute;left:50%;bottom:72px;transform:translateX(-50%);display:inline-flex;align-items:center;gap:4px;padding:6px 12px;border-radius:999px;border:1px solid color-mix(in srgb,currentColor 15%,transparent);background:var(--biab-chatbot-bg,Canvas);font-size:12px;font-weight:500;cursor:pointer;box-shadow:0 2px 10px color-mix(in srgb,currentColor 20%,transparent);}
|
|
440
|
+
:where([data-biab-chatbot] [data-slot="form-card"]){display:flex;flex-direction:column;gap:10px;align-self:stretch;padding:14px;border-radius:14px;border:1px solid color-mix(in srgb,currentColor 14%,transparent);background:color-mix(in srgb,currentColor 4%,transparent);}
|
|
441
|
+
:where([data-biab-chatbot] [data-slot="form-title"]){font-weight:600;}
|
|
442
|
+
:where([data-biab-chatbot] [data-slot="form-description"]){font-size:12px;opacity:.7;}
|
|
443
|
+
:where([data-biab-chatbot] [data-slot="form-field"]){gap:4px;}
|
|
444
|
+
:where([data-biab-chatbot] [data-slot="form-label"]){font-size:12px;font-weight:500;opacity:.85;}
|
|
445
|
+
:where([data-biab-chatbot] [data-slot="form-input"],[data-biab-chatbot] [data-slot="form-textarea"],[data-biab-chatbot] [data-slot="form-select"]){padding:8px 10px;border-radius:8px;border:1px solid color-mix(in srgb,currentColor 15%,transparent);background:transparent;color:inherit;font:inherit;outline:none;}
|
|
446
|
+
:where([data-biab-chatbot] [data-slot="form-input"]:focus-visible,[data-biab-chatbot] [data-slot="form-textarea"]:focus-visible,[data-biab-chatbot] [data-slot="form-select"]:focus-visible){border-color:var(--biab-chatbot-accent,#0d9488);}
|
|
447
|
+
:where([data-biab-chatbot] [data-slot="form-submit"]){padding:8px 14px;border-radius:8px;border:none;background:var(--biab-chatbot-accent,#0d9488);color:#fff;font-weight:500;cursor:pointer;}
|
|
448
|
+
:where([data-biab-chatbot] [data-slot="form-submit"]:disabled){opacity:.5;cursor:default;}
|
|
449
|
+
:where([data-biab-chatbot] [data-slot="form-error"]){font-size:12px;color:#dc2626;}
|
|
450
|
+
:where([data-biab-chatbot] [data-slot="form-receipt"]){font-size:13px;opacity:.8;}
|
|
451
|
+
@keyframes biab-chatbot-shimmer{from{background-position:100% 0;}to{background-position:0 0;}}
|
|
452
|
+
@media (prefers-reduced-motion:reduce){:where([data-biab-chatbot] [data-slot="message-pending"]){animation:none;-webkit-text-fill-color:currentColor;}}
|
|
453
|
+
@property --biab-chatbot-fade{syntax:"<length>";inherits:false;initial-value:0px;}
|
|
454
|
+
@keyframes biab-chatbot-fade-b{from{--biab-chatbot-fade:24px;}to{--biab-chatbot-fade:0px;}}
|
|
455
|
+
@supports (animation-timeline:scroll()){:where([data-biab-chatbot] [data-slot="message-list"]){-webkit-mask-image:linear-gradient(to bottom,#000 0,#000 calc(100% - var(--biab-chatbot-fade,0px)),transparent 100%);mask-image:linear-gradient(to bottom,#000 0,#000 calc(100% - var(--biab-chatbot-fade,0px)),transparent 100%);animation:biab-chatbot-fade-b 1ms linear both;animation-timeline:scroll(self y);animation-range:calc(100% - 96px) 100%;}}
|
|
456
|
+
`;
|
|
457
|
+
/**
|
|
458
|
+
* In-tree React chatbot component. Same backend wiring as the iframe
|
|
459
|
+
* `<Chatbot />` (chat → uiActions → form fetch → submit + CRM mirrors,
|
|
460
|
+
* plus availability polling) but rendered directly in the consumer's
|
|
461
|
+
* DOM tree so they can style every piece with their own CSS / Tailwind.
|
|
462
|
+
*
|
|
463
|
+
* For zero-customization drop-in use, prefer `<Chatbot />` (iframe).
|
|
464
|
+
* For fully custom UI, use the `useChatbot()` hook directly.
|
|
465
|
+
*/
|
|
466
|
+
export function ChatbotInline({ apiKey, baseUrl, welcomeMessage, title = "Assistant", subtitle = null, classNames = {}, className, style, unstyled = false, availabilityPollMs = 30_000, renderMessage, renderPresenceStrip, onAssistantMessage, }) {
|
|
467
|
+
const { messages, send, isPending, error, latestUiActions, availability, refreshAvailability, getForm, submitForm, getConfig, uploadFile, } = useChatbot({ apiKey, baseUrl });
|
|
468
|
+
const [thread, setThread] = useState(() => welcomeMessage
|
|
469
|
+
? [{ kind: "text", role: "assistant", content: welcomeMessage }]
|
|
470
|
+
: []);
|
|
471
|
+
const [draft, setDraft] = useState("");
|
|
472
|
+
const lastUserCount = useRef(0);
|
|
473
|
+
const lastAssistantCount = useRef(0);
|
|
474
|
+
const handledActionFingerprint = useRef("");
|
|
475
|
+
const scrollRef = useRef(null);
|
|
476
|
+
// Follow the live edge only while the reader is already at the bottom; the
|
|
477
|
+
// jump-to-latest control surfaces when they've scrolled up.
|
|
478
|
+
const followRef = useRef(true);
|
|
479
|
+
const lastThreadLen = useRef(0);
|
|
480
|
+
const [showJump, setShowJump] = useState(false);
|
|
481
|
+
// Visitor file uploads (entitlement-gated) + email capture.
|
|
482
|
+
const uploadSessionIdRef = useRef(null);
|
|
483
|
+
const fileUploadInputRef = useRef(null);
|
|
484
|
+
const [stagedFile, setStagedFile] = useState(null);
|
|
485
|
+
const [stagedPreview, setStagedPreview] = useState(null);
|
|
486
|
+
const [fileUploadsEnabled, setFileUploadsEnabled] = useState(false);
|
|
487
|
+
const [emailCaptureMode, setEmailCaptureMode] = useState("off");
|
|
488
|
+
const [capturedEmail, setCapturedEmail] = useState(null);
|
|
489
|
+
const [emailDraft, setEmailDraft] = useState("");
|
|
490
|
+
const [emailRequestDismissed, setEmailRequestDismissed] = useState(false);
|
|
491
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
492
|
+
const [uploadError, setUploadError] = useState(null);
|
|
493
|
+
// Inject the default visual theme once (id-guarded). Skipped when `unstyled`.
|
|
494
|
+
// The rules are zero-specificity `:where(…)`, so any `classNames` a consumer
|
|
495
|
+
// passes still win — this only styles the otherwise-bare default drop-in.
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
if (unstyled || typeof document === "undefined")
|
|
498
|
+
return;
|
|
499
|
+
if (document.getElementById(CHATBOT_INLINE_STYLE_ID))
|
|
500
|
+
return;
|
|
501
|
+
const styleEl = document.createElement("style");
|
|
502
|
+
styleEl.id = CHATBOT_INLINE_STYLE_ID;
|
|
503
|
+
styleEl.textContent = CHATBOT_INLINE_CSS;
|
|
504
|
+
document.head.appendChild(styleEl);
|
|
505
|
+
}, [unstyled]);
|
|
506
|
+
const scrollToEnd = useCallback((behavior = "auto") => {
|
|
507
|
+
const el = scrollRef.current;
|
|
508
|
+
if (!el)
|
|
509
|
+
return;
|
|
510
|
+
// Defer one frame so React has flushed the new node before we measure.
|
|
511
|
+
requestAnimationFrame(() => {
|
|
512
|
+
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
513
|
+
});
|
|
514
|
+
}, []);
|
|
515
|
+
// Track whether the reader is at the live edge (24px slack); toggle the jump
|
|
516
|
+
// control accordingly.
|
|
517
|
+
const handleScroll = useCallback(() => {
|
|
518
|
+
const el = scrollRef.current;
|
|
519
|
+
if (!el)
|
|
520
|
+
return;
|
|
521
|
+
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 24;
|
|
522
|
+
followRef.current = bottom;
|
|
523
|
+
setShowJump(!bottom && el.scrollHeight - el.clientHeight > 24);
|
|
524
|
+
}, []);
|
|
525
|
+
const jumpToLatest = useCallback(() => {
|
|
526
|
+
followRef.current = true;
|
|
527
|
+
setShowJump(false);
|
|
528
|
+
scrollToEnd("smooth");
|
|
529
|
+
}, [scrollToEnd]);
|
|
530
|
+
// Mirror useChatbot's `messages` into the thread so user input + assistant
|
|
531
|
+
// replies show up alongside any form cards we've stitched in.
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
const prevUser = lastUserCount.current;
|
|
534
|
+
const prevAssistant = lastAssistantCount.current;
|
|
535
|
+
const userMsgs = messages.filter((m) => m.role === "user");
|
|
536
|
+
const assistantMsgs = messages.filter((m) => m.role === "assistant");
|
|
537
|
+
if (userMsgs.length > prevUser) {
|
|
538
|
+
const newOnes = userMsgs.slice(prevUser);
|
|
539
|
+
setThread((prev) => [
|
|
540
|
+
...prev,
|
|
541
|
+
...newOnes.map((m) => ({
|
|
542
|
+
kind: "text",
|
|
543
|
+
role: "user",
|
|
544
|
+
content: m.content,
|
|
545
|
+
})),
|
|
546
|
+
]);
|
|
547
|
+
lastUserCount.current = userMsgs.length;
|
|
548
|
+
}
|
|
549
|
+
if (assistantMsgs.length > prevAssistant) {
|
|
550
|
+
const newOnes = assistantMsgs.slice(prevAssistant);
|
|
551
|
+
setThread((prev) => [
|
|
552
|
+
...prev,
|
|
553
|
+
...newOnes.map((m) => ({
|
|
554
|
+
kind: "text",
|
|
555
|
+
role: "assistant",
|
|
556
|
+
content: m.content,
|
|
557
|
+
})),
|
|
558
|
+
]);
|
|
559
|
+
const lastReply = newOnes[newOnes.length - 1]?.content;
|
|
560
|
+
if (lastReply)
|
|
561
|
+
onAssistantMessage?.(lastReply);
|
|
562
|
+
lastAssistantCount.current = assistantMsgs.length;
|
|
563
|
+
}
|
|
564
|
+
}, [messages, onAssistantMessage]);
|
|
565
|
+
// Resolve `show_form` UI actions into inline form cards. Fingerprint by
|
|
566
|
+
// turn so we don't double-fetch the same recommendation.
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
if (!latestUiActions.length)
|
|
569
|
+
return;
|
|
570
|
+
const fingerprint = latestUiActions
|
|
571
|
+
.map((a) => (a.kind === "show_form" ? a.formSlug : a.kind))
|
|
572
|
+
.join("|");
|
|
573
|
+
if (fingerprint === handledActionFingerprint.current)
|
|
574
|
+
return;
|
|
575
|
+
handledActionFingerprint.current = fingerprint;
|
|
576
|
+
let cancelled = false;
|
|
577
|
+
void (async () => {
|
|
578
|
+
for (const action of latestUiActions) {
|
|
579
|
+
if (action.kind !== "show_form")
|
|
580
|
+
continue;
|
|
581
|
+
const form = await getForm(action.formSlug);
|
|
582
|
+
if (cancelled || !form)
|
|
583
|
+
continue;
|
|
584
|
+
setThread((prev) => {
|
|
585
|
+
if (prev.some((item) => item.kind === "form" && item.form.slug === form.slug)) {
|
|
586
|
+
return prev;
|
|
587
|
+
}
|
|
588
|
+
return [...prev, { kind: "form", form, submitted: false }];
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
})();
|
|
592
|
+
return () => {
|
|
593
|
+
cancelled = true;
|
|
594
|
+
};
|
|
595
|
+
}, [latestUiActions, getForm]);
|
|
596
|
+
// New content: anchor a new USER turn near the top (with a 64px peek of the
|
|
597
|
+
// prior item) so the reply grows into view below it; otherwise follow the
|
|
598
|
+
// live edge only while the reader is already at the bottom. Replaces the old
|
|
599
|
+
// unconditional scroll-to-bottom that yanked readers who had scrolled up.
|
|
600
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: reads live DOM; thread/isPending are the triggers
|
|
601
|
+
useEffect(() => {
|
|
602
|
+
const el = scrollRef.current;
|
|
603
|
+
if (!el)
|
|
604
|
+
return;
|
|
605
|
+
const grew = thread.length > lastThreadLen.current;
|
|
606
|
+
const newest = thread[thread.length - 1];
|
|
607
|
+
const newestIsUser = grew && newest?.kind === "text" && newest.role === "user";
|
|
608
|
+
lastThreadLen.current = thread.length;
|
|
609
|
+
requestAnimationFrame(() => {
|
|
610
|
+
if (newestIsUser) {
|
|
611
|
+
const rows = el.querySelectorAll("[data-thread-row]");
|
|
612
|
+
const row = rows[rows.length - 1];
|
|
613
|
+
if (row) {
|
|
614
|
+
const target = row.getBoundingClientRect().top -
|
|
615
|
+
el.getBoundingClientRect().top +
|
|
616
|
+
el.scrollTop -
|
|
617
|
+
64;
|
|
618
|
+
// Anchor only if the turn would otherwise overflow; else rest at end.
|
|
619
|
+
if (el.scrollHeight - target > el.clientHeight) {
|
|
620
|
+
el.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
|
|
621
|
+
followRef.current = false;
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
el.scrollTop = el.scrollHeight;
|
|
625
|
+
followRef.current = true;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else if (followRef.current) {
|
|
630
|
+
el.scrollTop = el.scrollHeight;
|
|
631
|
+
}
|
|
632
|
+
handleScroll();
|
|
633
|
+
});
|
|
634
|
+
}, [thread, isPending, handleScroll]);
|
|
635
|
+
useEffect(() => {
|
|
636
|
+
void refreshAvailability();
|
|
637
|
+
if (availabilityPollMs <= 0)
|
|
638
|
+
return;
|
|
639
|
+
const id = setInterval(() => {
|
|
640
|
+
void refreshAvailability();
|
|
641
|
+
}, availabilityPollMs);
|
|
642
|
+
return () => clearInterval(id);
|
|
643
|
+
}, [refreshAvailability, availabilityPollMs]);
|
|
644
|
+
// Read the org's upload + email-capture config once on mount.
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
let cancelled = false;
|
|
647
|
+
void getConfig()
|
|
648
|
+
.then((cfg) => {
|
|
649
|
+
if (cancelled)
|
|
650
|
+
return;
|
|
651
|
+
setFileUploadsEnabled(cfg.fileUploadsEnabled);
|
|
652
|
+
setEmailCaptureMode(cfg.emailCaptureMode);
|
|
653
|
+
})
|
|
654
|
+
.catch(() => { });
|
|
655
|
+
return () => {
|
|
656
|
+
cancelled = true;
|
|
657
|
+
};
|
|
658
|
+
}, [getConfig]);
|
|
659
|
+
const handleSend = useCallback(async () => {
|
|
660
|
+
const trimmed = draft.trim();
|
|
661
|
+
if ((!trimmed && !stagedFile) || isPending || isUploading)
|
|
662
|
+
return;
|
|
663
|
+
// Upload a staged file first so it posts together with the text.
|
|
664
|
+
if (stagedFile) {
|
|
665
|
+
if (!uploadSessionIdRef.current) {
|
|
666
|
+
uploadSessionIdRef.current = crypto.randomUUID();
|
|
667
|
+
}
|
|
668
|
+
setUploadError(null);
|
|
669
|
+
setIsUploading(true);
|
|
670
|
+
try {
|
|
671
|
+
const fileRef = await uploadFile({
|
|
672
|
+
sessionId: uploadSessionIdRef.current,
|
|
673
|
+
file: stagedFile,
|
|
674
|
+
uploaderEmail: capturedEmail,
|
|
675
|
+
});
|
|
676
|
+
setThread((prev) => [...prev, { kind: "file", file: fileRef }]);
|
|
677
|
+
setStagedFile(null);
|
|
678
|
+
setStagedPreview(null);
|
|
679
|
+
}
|
|
680
|
+
catch (e) {
|
|
681
|
+
setUploadError(e instanceof Error ? e.message : "Upload failed.");
|
|
682
|
+
setIsUploading(false);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
setIsUploading(false);
|
|
686
|
+
}
|
|
687
|
+
if (trimmed) {
|
|
688
|
+
setDraft("");
|
|
689
|
+
await send(trimmed);
|
|
690
|
+
}
|
|
691
|
+
}, [
|
|
692
|
+
draft,
|
|
693
|
+
isPending,
|
|
694
|
+
isUploading,
|
|
695
|
+
stagedFile,
|
|
696
|
+
uploadFile,
|
|
697
|
+
capturedEmail,
|
|
698
|
+
send,
|
|
699
|
+
]);
|
|
700
|
+
const handleFormSubmit = useCallback(async (slug, data) => {
|
|
701
|
+
const submitterEmail = (typeof data["email"] === "string" ? data["email"] : undefined) ??
|
|
702
|
+
(typeof data["contact_email"] === "string"
|
|
703
|
+
? data["contact_email"]
|
|
704
|
+
: undefined);
|
|
705
|
+
const submitterName = (typeof data["name"] === "string" ? data["name"] : undefined) ??
|
|
706
|
+
(typeof data["full_name"] === "string" ? data["full_name"] : undefined);
|
|
707
|
+
const result = await submitForm(slug, {
|
|
708
|
+
data,
|
|
709
|
+
submitterEmail,
|
|
710
|
+
submitterName,
|
|
711
|
+
});
|
|
712
|
+
if (!result)
|
|
713
|
+
return false;
|
|
714
|
+
setThread((prev) => prev.map((item) => item.kind === "form" && item.form.slug === slug
|
|
715
|
+
? { ...item, submitted: true }
|
|
716
|
+
: item));
|
|
717
|
+
return true;
|
|
718
|
+
}, [submitForm]);
|
|
719
|
+
const userMessageCount = thread.filter((t) => t.kind === "text" && t.role === "user").length;
|
|
720
|
+
const needsEmailGate = emailCaptureMode === "require" && !capturedEmail && userMessageCount === 0;
|
|
721
|
+
const showEmailRequest = emailCaptureMode === "request" &&
|
|
722
|
+
!capturedEmail &&
|
|
723
|
+
!emailRequestDismissed &&
|
|
724
|
+
userMessageCount === 0;
|
|
725
|
+
const submitEmailGate = useCallback(() => {
|
|
726
|
+
const email = emailDraft.trim();
|
|
727
|
+
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
728
|
+
setCapturedEmail(email);
|
|
729
|
+
setEmailDraft("");
|
|
730
|
+
}
|
|
731
|
+
}, [emailDraft]);
|
|
732
|
+
const stageFile = useCallback((file) => {
|
|
733
|
+
// 7 MB cap (also enforced server-side on upload).
|
|
734
|
+
if (file.size > 7 * 1024 * 1024) {
|
|
735
|
+
setUploadError("That file is too large (max 7 MB).");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
setUploadError(null);
|
|
739
|
+
setStagedFile(file);
|
|
740
|
+
setStagedPreview(file.type.startsWith("image/") ? URL.createObjectURL(file) : null);
|
|
741
|
+
}, []);
|
|
742
|
+
const clearStaged = useCallback(() => {
|
|
743
|
+
setStagedFile(null);
|
|
744
|
+
setStagedPreview(null);
|
|
745
|
+
}, []);
|
|
746
|
+
useEffect(() => {
|
|
747
|
+
return () => {
|
|
748
|
+
if (stagedPreview)
|
|
749
|
+
URL.revokeObjectURL(stagedPreview);
|
|
750
|
+
};
|
|
751
|
+
}, [stagedPreview]);
|
|
752
|
+
return (_jsxs("div", { className: [classNames.root, className].filter(Boolean).join(" ") || undefined, "data-biab-chatbot": unstyled ? undefined : "", style: { ...inlineLayoutStyles.root, ...style }, children: [title !== null ? (_jsxs("div", { className: classNames.header, "data-slot": "header", children: [_jsx("div", { className: classNames.headerTitle, "data-slot": "header-title", children: title }), subtitle !== null && subtitle !== undefined ? (_jsx("div", { className: classNames.headerSubtitle, "data-slot": "header-subtitle", children: subtitle })) : null] })) : null, availability?.frontDeskEnabled
|
|
753
|
+
? renderPresenceStrip
|
|
754
|
+
? renderPresenceStrip(availability)
|
|
755
|
+
: defaultPresenceStrip({ availability, classNames })
|
|
756
|
+
: null, _jsxs("div", { className: classNames.messageList, "data-slot": "message-list", onScroll: handleScroll, ref: scrollRef, style: inlineLayoutStyles.messageList, children: [thread.map((item, idx) => {
|
|
757
|
+
if (renderMessage)
|
|
758
|
+
return renderMessage(item, idx);
|
|
759
|
+
if (item.kind === "form") {
|
|
760
|
+
return (_jsx(InlineFormCard, { classNames: classNames, form: item.form, onSubmit: (data) => handleFormSubmit(item.form.slug, data), submitted: item.submitted }, idx));
|
|
761
|
+
}
|
|
762
|
+
if (item.kind === "file") {
|
|
763
|
+
const isImage = item.file.type.startsWith("image/");
|
|
764
|
+
return (_jsx("a", { className: isImage ? undefined : classNames.messageUser, "data-role": "user", "data-slot": isImage ? "message-image" : "message-file", "data-thread-row": "", href: item.file.url, rel: "noreferrer", target: "_blank", children: isImage ? (_jsx("img", { alt: item.file.name, src: item.file.url, style: {
|
|
765
|
+
display: "block",
|
|
766
|
+
maxWidth: "100%",
|
|
767
|
+
maxHeight: 220,
|
|
768
|
+
borderRadius: 12,
|
|
769
|
+
} })) : (`📎 ${item.file.name}`) }, idx));
|
|
770
|
+
}
|
|
771
|
+
const bubbleClass = item.role === "user"
|
|
772
|
+
? classNames.messageUser
|
|
773
|
+
: classNames.messageAssistant;
|
|
774
|
+
return (_jsx("div", { className: [classNames.messageRow, bubbleClass]
|
|
775
|
+
.filter(Boolean)
|
|
776
|
+
.join(" ") || undefined, "data-role": item.role, "data-slot": "message-row", "data-thread-row": "", children: item.content }, idx));
|
|
777
|
+
}), isPending ? (_jsx("div", { className: classNames.messagePending, "data-slot": "message-pending", children: "\u2026" })) : null, error ? (_jsx("div", { className: classNames.errorText, "data-slot": "error-text", children: error.message })) : null] }), showJump ? (_jsx("button", { "aria-label": "Jump to latest", className: classNames.jumpButton, "data-slot": "jump-latest", onClick: jumpToLatest, type: "button", children: "\u2193 Latest" })) : null, showEmailRequest ? (_jsxs("form", { "data-slot": "email-request", onSubmit: (e) => {
|
|
778
|
+
e.preventDefault();
|
|
779
|
+
submitEmailGate();
|
|
780
|
+
}, style: {
|
|
781
|
+
display: "flex",
|
|
782
|
+
gap: 8,
|
|
783
|
+
alignItems: "center",
|
|
784
|
+
padding: "8px 12px",
|
|
785
|
+
fontSize: 12,
|
|
786
|
+
}, children: [_jsx("input", { onChange: (e) => setEmailDraft(e.target.value), placeholder: "Your email (optional)", style: {
|
|
787
|
+
flex: 1,
|
|
788
|
+
background: "transparent",
|
|
789
|
+
border: "none",
|
|
790
|
+
outline: "none",
|
|
791
|
+
color: "inherit",
|
|
792
|
+
}, type: "email", value: emailDraft }), _jsx("button", { style: {
|
|
793
|
+
background: "none",
|
|
794
|
+
border: "none",
|
|
795
|
+
cursor: "pointer",
|
|
796
|
+
color: "inherit",
|
|
797
|
+
fontWeight: 500,
|
|
798
|
+
}, type: "submit", children: "Share" }), _jsx("button", { onClick: () => setEmailRequestDismissed(true), style: {
|
|
799
|
+
background: "none",
|
|
800
|
+
border: "none",
|
|
801
|
+
cursor: "pointer",
|
|
802
|
+
color: "inherit",
|
|
803
|
+
opacity: 0.7,
|
|
804
|
+
}, type: "button", children: "Skip" })] })) : null, needsEmailGate ? (_jsxs("form", { className: classNames.inputBar, "data-slot": "input-bar", onSubmit: (e) => {
|
|
805
|
+
e.preventDefault();
|
|
806
|
+
submitEmailGate();
|
|
807
|
+
}, style: inlineLayoutStyles.inputBar, children: [_jsx("input", { className: classNames.inputField, "data-slot": "input-field", onChange: (e) => setEmailDraft(e.target.value), placeholder: "Enter your email to start chatting", required: true, style: inlineLayoutStyles.inputField, type: "email", value: emailDraft }), _jsx("button", { className: classNames.sendButton, "data-slot": "send-button", type: "submit", children: "Start" })] })) : (_jsxs("form", { className: classNames.inputBar, "data-slot": "input-bar", onSubmit: (e) => {
|
|
808
|
+
e.preventDefault();
|
|
809
|
+
void handleSend();
|
|
810
|
+
}, style: inlineLayoutStyles.inputBar, children: [fileUploadsEnabled ? (_jsxs(_Fragment, { children: [_jsx("input", { onChange: (e) => {
|
|
811
|
+
const f = e.target.files?.[0];
|
|
812
|
+
e.target.value = "";
|
|
813
|
+
if (f)
|
|
814
|
+
stageFile(f);
|
|
815
|
+
}, ref: fileUploadInputRef, style: { display: "none" }, type: "file" }), _jsx("button", { "aria-label": "Attach a file", "data-slot": "upload-button", disabled: isPending, onClick: () => fileUploadInputRef.current?.click(), type: "button", children: "\uD83D\uDCCE" })] })) : null, _jsx("input", { className: classNames.inputField, "data-slot": "input-field", disabled: isPending, onChange: (e) => setDraft(e.target.value), placeholder: "Type a message\u2026", style: inlineLayoutStyles.inputField, value: draft }), _jsx("button", { className: classNames.sendButton, "data-slot": "send-button", disabled: isPending || isUploading || (!draft.trim() && !stagedFile), type: "submit", children: "Send" })] })), stagedFile ? (_jsxs("div", { "data-slot": "staged-attachment", style: {
|
|
816
|
+
display: "flex",
|
|
817
|
+
alignItems: "center",
|
|
818
|
+
gap: 8,
|
|
819
|
+
padding: "8px 12px",
|
|
820
|
+
borderTop: "1px solid color-mix(in srgb,currentColor 10%,transparent)",
|
|
821
|
+
}, children: [stagedPreview ? (_jsx("img", { alt: stagedFile.name, src: stagedPreview, style: {
|
|
822
|
+
width: 36,
|
|
823
|
+
height: 36,
|
|
824
|
+
borderRadius: 6,
|
|
825
|
+
objectFit: "cover",
|
|
826
|
+
flexShrink: 0,
|
|
827
|
+
} })) : (_jsx("span", { style: { fontSize: 18, flexShrink: 0 }, children: "\uD83D\uDCCE" })), _jsx("span", { style: {
|
|
828
|
+
flex: 1,
|
|
829
|
+
minWidth: 0,
|
|
830
|
+
overflow: "hidden",
|
|
831
|
+
textOverflow: "ellipsis",
|
|
832
|
+
whiteSpace: "nowrap",
|
|
833
|
+
fontSize: 13,
|
|
834
|
+
}, children: stagedFile.name }), _jsx("button", { "aria-label": "Remove file", onClick: clearStaged, style: {
|
|
835
|
+
border: "none",
|
|
836
|
+
background: "transparent",
|
|
837
|
+
color: "inherit",
|
|
838
|
+
cursor: "pointer",
|
|
839
|
+
opacity: 0.7,
|
|
840
|
+
fontSize: 16,
|
|
841
|
+
}, type: "button", children: "\u2715" })] })) : null, uploadError ? (_jsx("div", { className: classNames.errorText, "data-slot": "error-text", style: { padding: "0 12px 8px" }, children: uploadError })) : null] }));
|
|
842
|
+
}
|
|
843
|
+
function defaultPresenceStrip({ availability, classNames, }) {
|
|
844
|
+
const { staffAvailable, offline, estimateLabel, onlineHandlers } = availability;
|
|
845
|
+
const stateClass = offline
|
|
846
|
+
? classNames.presenceStripOffline
|
|
847
|
+
: staffAvailable
|
|
848
|
+
? classNames.presenceStripOnline
|
|
849
|
+
: classNames.presenceStripAway;
|
|
850
|
+
const label = offline
|
|
851
|
+
? "Team is offline — leave a message and we'll follow up."
|
|
852
|
+
: staffAvailable
|
|
853
|
+
? onlineHandlers.length === 0
|
|
854
|
+
? "Team is online — we're here."
|
|
855
|
+
: onlineHandlers.length === 1
|
|
856
|
+
? `${onlineHandlers[0]?.name} is here now.`
|
|
857
|
+
: `${onlineHandlers.length} teammates are here now.`
|
|
858
|
+
: "Team's away — we'll get back as soon as we can.";
|
|
859
|
+
return (_jsxs("div", { className: [classNames.presenceStrip, stateClass].filter(Boolean).join(" ") ||
|
|
860
|
+
undefined, "data-slot": "presence-strip", children: [_jsx("span", { className: classNames.presenceLabel, children: label }), estimateLabel ? (_jsx("span", { className: classNames.presenceEstimate, children: estimateLabel })) : null] }));
|
|
861
|
+
}
|
|
862
|
+
function InlineFormCard({ form, submitted, onSubmit, classNames, }) {
|
|
863
|
+
const [values, setValues] = useState({});
|
|
864
|
+
const [submitting, setSubmitting] = useState(false);
|
|
865
|
+
const [submitError, setSubmitError] = useState(null);
|
|
866
|
+
const inputFields = useMemo(() => {
|
|
867
|
+
const CONTAINER_FIELD_TYPES = new Set([
|
|
868
|
+
"input_group",
|
|
869
|
+
"condition_block",
|
|
870
|
+
"or_condition",
|
|
871
|
+
"flex_row",
|
|
872
|
+
"multi_populator",
|
|
873
|
+
]);
|
|
874
|
+
const DISPLAY_ONLY_TYPES = new Set([
|
|
875
|
+
...STATIC_FIELD_TYPES,
|
|
876
|
+
"body_text",
|
|
877
|
+
]);
|
|
878
|
+
const out = [];
|
|
879
|
+
const walk = (list) => {
|
|
880
|
+
for (const f of list ?? []) {
|
|
881
|
+
const children = f.children;
|
|
882
|
+
if (CONTAINER_FIELD_TYPES.has(f.type) || Array.isArray(children)) {
|
|
883
|
+
walk(children);
|
|
884
|
+
}
|
|
885
|
+
else if (!DISPLAY_ONLY_TYPES.has(f.type)) {
|
|
886
|
+
out.push(f);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
walk(form.fields);
|
|
891
|
+
return out;
|
|
892
|
+
}, [form.fields]);
|
|
893
|
+
if (submitted) {
|
|
894
|
+
return (_jsxs("div", { className: classNames.formReceipt, "data-slot": "form-receipt", children: ["Thanks \u2014 we got your ", form.name.toLowerCase(), "."] }));
|
|
895
|
+
}
|
|
896
|
+
return (_jsxs("form", { className: classNames.formCard, "data-slot": "form-card", onSubmit: async (e) => {
|
|
897
|
+
e.preventDefault();
|
|
898
|
+
setSubmitting(true);
|
|
899
|
+
setSubmitError(null);
|
|
900
|
+
const ok = await onSubmit(values);
|
|
901
|
+
if (!ok)
|
|
902
|
+
setSubmitError("Could not submit — please try again.");
|
|
903
|
+
setSubmitting(false);
|
|
904
|
+
}, children: [_jsxs("div", { children: [_jsx("div", { className: classNames.formTitle, "data-slot": "form-title", children: form.name }), form.description ? (_jsx("div", { className: classNames.formDescription, "data-slot": "form-description", children: form.description })) : null] }), inputFields.map((field) => {
|
|
905
|
+
const value = values[field.id] ?? "";
|
|
906
|
+
const setValue = (next) => setValues((prev) => ({ ...prev, [field.id]: next }));
|
|
907
|
+
if (field.type === "textarea") {
|
|
908
|
+
return (_jsxs("label", { className: classNames.formField, "data-slot": "form-field", style: inlineLayoutStyles.formField, children: [_jsxs("span", { className: classNames.formLabel, "data-slot": "form-label", children: [field.label, field.required ? " *" : ""] }), _jsx("textarea", { className: classNames.formTextarea, "data-slot": "form-textarea", onChange: (e) => setValue(e.target.value), placeholder: field.placeholder, required: field.required, rows: 3, value: typeof value === "string" ? value : "" })] }, field.id));
|
|
909
|
+
}
|
|
910
|
+
if (field.type === "select" && field.options?.length) {
|
|
911
|
+
return (_jsxs("label", { className: classNames.formField, "data-slot": "form-field", style: inlineLayoutStyles.formField, children: [_jsxs("span", { className: classNames.formLabel, "data-slot": "form-label", children: [field.label, field.required ? " *" : ""] }), _jsxs("select", { className: classNames.formSelect, "data-slot": "form-select", onChange: (e) => setValue(e.target.value), required: field.required, value: typeof value === "string" ? value : "", children: [_jsx("option", { value: "", children: "Choose\u2026" }), field.options.map((opt) => (_jsx("option", { value: opt, children: opt }, opt)))] })] }, field.id));
|
|
912
|
+
}
|
|
913
|
+
const inputType = field.type === "email"
|
|
914
|
+
? "email"
|
|
915
|
+
: field.type === "phone"
|
|
916
|
+
? "tel"
|
|
917
|
+
: field.type === "number"
|
|
918
|
+
? "number"
|
|
919
|
+
: "text";
|
|
920
|
+
return (_jsxs("label", { className: classNames.formField, style: inlineLayoutStyles.formField, children: [_jsxs("span", { className: classNames.formLabel, "data-slot": "form-label", children: [field.label, field.required ? " *" : ""] }), _jsx("input", { className: classNames.formInput, "data-slot": "form-input", onChange: (e) => setValue(e.target.value), placeholder: field.placeholder, required: field.required, type: inputType, value: typeof value === "string" ? value : "" })] }, field.id));
|
|
921
|
+
}), submitError ? (_jsx("div", { className: classNames.formError, "data-slot": "form-error", children: submitError })) : null, _jsx("button", { className: classNames.formSubmit, "data-slot": "form-submit", disabled: submitting, type: "submit", children: submitting ? "Sending…" : "Send" })] }));
|
|
922
|
+
}
|
|
923
|
+
export function Blog({ apiKey, baseUrl, postSlug, className, style, title = "Blog", loadingFallback, errorFallback, onEvent, refreshLeadMs, }) {
|
|
924
|
+
const input = useMemo(() => (postSlug ? { postSlug } : undefined), [postSlug]);
|
|
925
|
+
const { state, refresh } = useEmbedSession((client) => client.blog.createSession(input), { apiKey, baseUrl, refreshLeadMs });
|
|
926
|
+
return (_jsx(EmbedIframe, { className: className, errorFallback: errorFallback, loadingFallback: loadingFallback, onEvent: onEvent, refresh: refresh, state: state, style: style, title: title }));
|
|
927
|
+
}
|
|
928
|
+
const DEFAULT_AUTH_BASE_PATH = "/api/biab-auth";
|
|
929
|
+
function buildAuthHref(subroute, props) {
|
|
930
|
+
const base = (props.basePath ?? DEFAULT_AUTH_BASE_PATH).replace(/\/$/, "");
|
|
931
|
+
const params = new URLSearchParams();
|
|
932
|
+
if (props.returnTo)
|
|
933
|
+
params.set("returnTo", props.returnTo);
|
|
934
|
+
if (props.loginHint)
|
|
935
|
+
params.set("loginHint", props.loginHint);
|
|
936
|
+
const qs = params.toString();
|
|
937
|
+
return `${base}/${subroute}${qs ? `?${qs}` : ""}`;
|
|
938
|
+
}
|
|
939
|
+
export function SignIn({ children, ...rest }) {
|
|
940
|
+
return (_jsx("a", { className: rest.className, href: buildAuthHref("sign-in", rest), style: rest.style, children: children ?? "Sign in" }));
|
|
941
|
+
}
|
|
942
|
+
export function SignUp({ children, ...rest }) {
|
|
943
|
+
return (_jsx("a", { className: rest.className, href: buildAuthHref("sign-up", rest), style: rest.style, children: children ?? "Create account" }));
|
|
944
|
+
}
|
|
945
|
+
export function SignOut({ children, basePath, className, style, }) {
|
|
946
|
+
return (_jsx("a", { className: className, href: buildAuthHref("sign-out", { basePath }), style: style, children: children ?? "Sign out" }));
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Programmatic sign-in — navigates the browser through the auth handler's
|
|
950
|
+
* `/sign-in` route. Equivalent to clicking `<SignIn />`. Browser-only.
|
|
951
|
+
*/
|
|
952
|
+
export function signIn(options = {}) {
|
|
953
|
+
if (typeof window === "undefined")
|
|
954
|
+
return;
|
|
955
|
+
window.location.assign(buildAuthHref("sign-in", options));
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Programmatic sign-up — navigates through `/sign-up`. Equivalent to
|
|
959
|
+
* clicking `<SignUp />`. Browser-only.
|
|
960
|
+
*/
|
|
961
|
+
export function signUp(options = {}) {
|
|
962
|
+
if (typeof window === "undefined")
|
|
963
|
+
return;
|
|
964
|
+
window.location.assign(buildAuthHref("sign-up", options));
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Programmatic sign-out. Defaults to a redirect through the auth
|
|
968
|
+
* handler's `/sign-out` route (same as `<SignOut />`). Pass
|
|
969
|
+
* `{ mode: "fetch" }` for a fetch-based clear that resolves with
|
|
970
|
+
* `{ ok: true }` and does not navigate.
|
|
971
|
+
*/
|
|
972
|
+
export async function signOut(options = {}) {
|
|
973
|
+
if (typeof window === "undefined")
|
|
974
|
+
return { ok: false };
|
|
975
|
+
const base = (options.basePath ?? DEFAULT_AUTH_BASE_PATH).replace(/\/$/, "");
|
|
976
|
+
if (options.mode === "fetch") {
|
|
977
|
+
try {
|
|
978
|
+
const res = await fetch(`${base}/sign-out`, {
|
|
979
|
+
method: "POST",
|
|
980
|
+
credentials: "include",
|
|
981
|
+
});
|
|
982
|
+
return { ok: res.ok };
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
return { ok: false };
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
window.location.assign(`${base}/sign-out`);
|
|
989
|
+
return { ok: true };
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Programmatic "forgot password" — POSTs to the auth handler's
|
|
993
|
+
* `/password-reset` route, which the SDK forwards to BIAB. The server
|
|
994
|
+
* returns `{ ok: true }` regardless of whether an account matched
|
|
995
|
+
* (intentional anti-leak), and emails the platform-hosted reset link
|
|
996
|
+
* out-of-band when a match exists.
|
|
997
|
+
*/
|
|
998
|
+
export async function requestPasswordReset(input) {
|
|
999
|
+
if (typeof window === "undefined")
|
|
1000
|
+
return { ok: false };
|
|
1001
|
+
const base = (input.basePath ?? DEFAULT_AUTH_BASE_PATH).replace(/\/$/, "");
|
|
1002
|
+
const body = { email: input.email };
|
|
1003
|
+
if (input.returnTo)
|
|
1004
|
+
body.returnTo = input.returnTo;
|
|
1005
|
+
try {
|
|
1006
|
+
const res = await fetch(`${base}/password-reset`, {
|
|
1007
|
+
method: "POST",
|
|
1008
|
+
credentials: "include",
|
|
1009
|
+
headers: { "Content-Type": "application/json" },
|
|
1010
|
+
body: JSON.stringify(body),
|
|
1011
|
+
});
|
|
1012
|
+
return { ok: res.ok };
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
return { ok: false };
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Reads the current tenant session by hitting the `/me` route the SDK's
|
|
1020
|
+
* auth-handler exposes on the tenant's own domain. No props needed because
|
|
1021
|
+
* the cookie + handler path are the source of truth.
|
|
1022
|
+
*/
|
|
1023
|
+
export function useUser(options) {
|
|
1024
|
+
const basePath = (options?.basePath ?? DEFAULT_AUTH_BASE_PATH).replace(/\/$/, "");
|
|
1025
|
+
const [state, setState] = useState({
|
|
1026
|
+
status: "loading",
|
|
1027
|
+
user: null,
|
|
1028
|
+
});
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
let cancelled = false;
|
|
1031
|
+
fetch(`${basePath}/me`, { credentials: "include" })
|
|
1032
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
1033
|
+
.then((body) => {
|
|
1034
|
+
if (cancelled)
|
|
1035
|
+
return;
|
|
1036
|
+
if (body) {
|
|
1037
|
+
setState({ status: "signed-in", user: body });
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
setState({ status: "signed-out", user: null });
|
|
1041
|
+
}
|
|
1042
|
+
})
|
|
1043
|
+
.catch(() => {
|
|
1044
|
+
if (cancelled)
|
|
1045
|
+
return;
|
|
1046
|
+
setState({ status: "signed-out", user: null });
|
|
1047
|
+
});
|
|
1048
|
+
return () => {
|
|
1049
|
+
cancelled = true;
|
|
1050
|
+
};
|
|
1051
|
+
}, [basePath]);
|
|
1052
|
+
return state;
|
|
1053
|
+
}
|
|
1054
|
+
const FOLLOWER_STORE_PREFIX = "biab.followers.";
|
|
1055
|
+
function readLocalFollow(key) {
|
|
1056
|
+
if (typeof window === "undefined")
|
|
1057
|
+
return null;
|
|
1058
|
+
try {
|
|
1059
|
+
const raw = window.localStorage.getItem(key);
|
|
1060
|
+
if (!raw)
|
|
1061
|
+
return null;
|
|
1062
|
+
const parsed = JSON.parse(raw);
|
|
1063
|
+
return typeof parsed?.email === "string"
|
|
1064
|
+
? { email: parsed.email, at: Number(parsed.at) || 0 }
|
|
1065
|
+
: null;
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
function writeLocalFollow(key, value) {
|
|
1072
|
+
if (typeof window === "undefined")
|
|
1073
|
+
return;
|
|
1074
|
+
try {
|
|
1075
|
+
window.localStorage.setItem(key, JSON.stringify(value));
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
// storage disabled / quota — the local hint is best-effort.
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function clearLocalFollow(key) {
|
|
1082
|
+
if (typeof window === "undefined")
|
|
1083
|
+
return;
|
|
1084
|
+
try {
|
|
1085
|
+
window.localStorage.removeItem(key);
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
// ignore
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Visitor-facing followers/subscribe client, bound to a publishable token so it
|
|
1093
|
+
* works browser-side with no backend.
|
|
1094
|
+
*
|
|
1095
|
+
* const f = useFollowers({ token: pk, siteId });
|
|
1096
|
+
* if (!f.subscribedLocally) showForm(); // anonymous "already subscribed?" hint
|
|
1097
|
+
* await f.join({ email, name, source: "footer" }); // subscribe (+ remembers this browser)
|
|
1098
|
+
* const { follower } = await f.me(user.email); // authoritative status (logged-in)
|
|
1099
|
+
* await f.leave(email); // unsubscribe (+ forgets)
|
|
1100
|
+
*
|
|
1101
|
+
* Source of truth for "is this visitor subscribed":
|
|
1102
|
+
* - Logged-in → pass `user.email` to `me()` (authoritative, cross-device).
|
|
1103
|
+
* - Anonymous → `subscribedLocally` — a per-browser flag set on `join()`.
|
|
1104
|
+
* Email is the real key; this is just a privacy-friendly UX hint so an
|
|
1105
|
+
* anonymous visitor isn't shown the form forever. Clearing storage / a new
|
|
1106
|
+
* device re-shows it, and re-subscribing is idempotent (no dupes). IP is
|
|
1107
|
+
* deliberately NOT used (shared NAT hides the form from non-subscribers).
|
|
1108
|
+
*
|
|
1109
|
+
* `useUser()` covers the auth session; this covers follower status + subscribe,
|
|
1110
|
+
* which the session doesn't include. All actions are `followers:self`, so a
|
|
1111
|
+
* publishable token can run them.
|
|
1112
|
+
*/
|
|
1113
|
+
export function useFollowers(options) {
|
|
1114
|
+
const { token, siteId, baseUrl } = options;
|
|
1115
|
+
const client = useMemo(() => createBiabClient({
|
|
1116
|
+
apiKey: token,
|
|
1117
|
+
siteId,
|
|
1118
|
+
baseUrl: baseUrl ?? DEFAULT_PACKAGE_BASE_URL,
|
|
1119
|
+
}), [token, siteId, baseUrl]);
|
|
1120
|
+
const storeKey = `${FOLLOWER_STORE_PREFIX}${siteId}`;
|
|
1121
|
+
const [local, setLocal] = useState(() => readLocalFollow(storeKey));
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
setLocal(readLocalFollow(storeKey));
|
|
1124
|
+
}, [storeKey]);
|
|
1125
|
+
const join = useCallback(async (input) => {
|
|
1126
|
+
const res = await client.followers.join(input);
|
|
1127
|
+
const entry = { email: input.email, at: Date.now() };
|
|
1128
|
+
writeLocalFollow(storeKey, entry);
|
|
1129
|
+
setLocal(entry);
|
|
1130
|
+
return res;
|
|
1131
|
+
}, [client, storeKey]);
|
|
1132
|
+
const leave = useCallback(async (email) => {
|
|
1133
|
+
const res = await client.followers.leave({ email });
|
|
1134
|
+
clearLocalFollow(storeKey);
|
|
1135
|
+
setLocal(null);
|
|
1136
|
+
return res;
|
|
1137
|
+
}, [client, storeKey]);
|
|
1138
|
+
const me = useCallback((email) => client.followers.me({ email }), [client]);
|
|
1139
|
+
const edit = useCallback((email, patch) => client.followers.edit({ email, patch }), [client]);
|
|
1140
|
+
return useMemo(() => ({
|
|
1141
|
+
join,
|
|
1142
|
+
leave,
|
|
1143
|
+
me,
|
|
1144
|
+
edit,
|
|
1145
|
+
/** This browser already subscribed (instant, no network). Use to hide a
|
|
1146
|
+
* newsletter form for anonymous visitors. */
|
|
1147
|
+
subscribedLocally: local !== null,
|
|
1148
|
+
/** Email this browser subscribed with — pass to `me()` for an
|
|
1149
|
+
* authoritative re-check, or `null` if this browser hasn't subscribed. */
|
|
1150
|
+
localEmail: local?.email ?? null,
|
|
1151
|
+
}), [join, leave, me, edit, local]);
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Lazy-fetch a single marketing page by `pageKey`. The hook reads from the
|
|
1155
|
+
* SDK's shared cache when present (warmed by `useMarketingPagePreloader` or
|
|
1156
|
+
* a previous `useMarketingPage` render) and only hits the network when the
|
|
1157
|
+
* cache is empty — so navigating between pages on a many-page site only
|
|
1158
|
+
* fetches the page the visitor actually views.
|
|
1159
|
+
*
|
|
1160
|
+
* Pass `enabled: false` to gate the request on something async (e.g., wait
|
|
1161
|
+
* for the visitor to consent before loading the privacy page).
|
|
1162
|
+
*/
|
|
1163
|
+
export function useMarketingPage(options) {
|
|
1164
|
+
const { apiKey, baseUrl, siteId, pageKey, enabled = true } = options;
|
|
1165
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
1166
|
+
const initial = client.site(siteId).marketingPages.getCached(pageKey);
|
|
1167
|
+
const [state, setState] = useState(initial
|
|
1168
|
+
? { status: "ready", page: initial.page }
|
|
1169
|
+
: { status: "idle", page: null });
|
|
1170
|
+
const fetchOnce = useCallback(async (skipCache = false) => {
|
|
1171
|
+
if (!enabled)
|
|
1172
|
+
return;
|
|
1173
|
+
setState({ status: "loading", page: null });
|
|
1174
|
+
try {
|
|
1175
|
+
const response = await client
|
|
1176
|
+
.site(siteId)
|
|
1177
|
+
.marketingPages.get(pageKey, { skipCache });
|
|
1178
|
+
setState({ status: "ready", page: response.page });
|
|
1179
|
+
}
|
|
1180
|
+
catch (err) {
|
|
1181
|
+
setState({
|
|
1182
|
+
status: "error",
|
|
1183
|
+
page: null,
|
|
1184
|
+
error: err instanceof Error
|
|
1185
|
+
? err
|
|
1186
|
+
: new Error("Marketing page load failed"),
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}, [client, siteId, pageKey, enabled]);
|
|
1190
|
+
useEffect(() => {
|
|
1191
|
+
if (!enabled)
|
|
1192
|
+
return;
|
|
1193
|
+
// Cache hit: render synchronously, no fetch.
|
|
1194
|
+
const hit = client.site(siteId).marketingPages.getCached(pageKey);
|
|
1195
|
+
if (hit) {
|
|
1196
|
+
setState({ status: "ready", page: hit.page });
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
void fetchOnce(false);
|
|
1200
|
+
}, [client, siteId, pageKey, enabled, fetchOnce]);
|
|
1201
|
+
const refresh = useCallback(() => fetchOnce(true), [fetchOnce]);
|
|
1202
|
+
return { ...state, refresh };
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Loads the payload-free directory of marketing pages for a site. Pair with
|
|
1206
|
+
* `useMarketingPage(siteId, pageKey)` to render the selected page lazily.
|
|
1207
|
+
*/
|
|
1208
|
+
export function useMarketingPagesList(options) {
|
|
1209
|
+
const { apiKey, baseUrl, siteId, enabled = true } = options;
|
|
1210
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
1211
|
+
const [state, setState] = useState({
|
|
1212
|
+
status: "idle",
|
|
1213
|
+
pages: null,
|
|
1214
|
+
});
|
|
1215
|
+
const fetchOnce = useCallback(async () => {
|
|
1216
|
+
if (!enabled)
|
|
1217
|
+
return;
|
|
1218
|
+
setState({ status: "loading", pages: null });
|
|
1219
|
+
try {
|
|
1220
|
+
const response = await client
|
|
1221
|
+
.site(siteId)
|
|
1222
|
+
.marketingPages.list();
|
|
1223
|
+
setState({ status: "ready", pages: response.pages });
|
|
1224
|
+
}
|
|
1225
|
+
catch (err) {
|
|
1226
|
+
setState({
|
|
1227
|
+
status: "error",
|
|
1228
|
+
pages: null,
|
|
1229
|
+
error: err instanceof Error ? err : new Error("Marketing pages list failed"),
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
}, [client, siteId, enabled]);
|
|
1233
|
+
useEffect(() => {
|
|
1234
|
+
void fetchOnce();
|
|
1235
|
+
}, [fetchOnce]);
|
|
1236
|
+
return { ...state, refresh: fetchOnce };
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Returns a stable `preload(pageKey)` function that warms the SDK cache for
|
|
1240
|
+
* the next page without rendering anything yet. Common pattern: wire it to
|
|
1241
|
+
* `<Link onPointerEnter>` / `onFocus` so the next page is ready by the time
|
|
1242
|
+
* the visitor clicks. Idempotent — repeat calls are free.
|
|
1243
|
+
*/
|
|
1244
|
+
export function useMarketingPagePreloader(options) {
|
|
1245
|
+
const { apiKey, baseUrl, siteId } = options;
|
|
1246
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
1247
|
+
return useCallback(async (pageKey) => {
|
|
1248
|
+
try {
|
|
1249
|
+
return await client.site(siteId).marketingPages.prefetch(pageKey);
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
}, [client, siteId]);
|
|
1255
|
+
}
|
|
1256
|
+
// ── Marketing-flow hooks (schema-driven, see ./react-marketing.tsx) ────────
|
|
1257
|
+
export { useMarketingBundlePreloader, useMarketingLocales, useMarketingPageBundle, useMarketingPageSeo, useMarketingSection, usePublishedMarketingSchema, } from "./react-marketing.js";
|
|
1258
|
+
// ── Customer notification destination verification (0.9.0) ─────────────────
|
|
1259
|
+
/**
|
|
1260
|
+
* Drop-in email-verification widget for the customer's notification
|
|
1261
|
+
* preferences page. Customer enters an email, we mint + send a
|
|
1262
|
+
* verification link, they click the link, the deep-link target
|
|
1263
|
+
* page consumes the token via {@link VerifyEmailConfirm}.
|
|
1264
|
+
*/
|
|
1265
|
+
export function VerifyEmailFlow(props) {
|
|
1266
|
+
const { apiKey, baseUrl, sessionToken, currentEmail } = props;
|
|
1267
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
1268
|
+
const [email, setEmail] = useState(currentEmail ?? "");
|
|
1269
|
+
const [sent, setSent] = useState(false);
|
|
1270
|
+
const [busy, setBusy] = useState(false);
|
|
1271
|
+
const [error, setError] = useState(null);
|
|
1272
|
+
const handleSend = useCallback(async () => {
|
|
1273
|
+
setError(null);
|
|
1274
|
+
setBusy(true);
|
|
1275
|
+
try {
|
|
1276
|
+
await client.notifications
|
|
1277
|
+
.withSession(sessionToken)
|
|
1278
|
+
.startVerification({ kind: "email", destination: email });
|
|
1279
|
+
setSent(true);
|
|
1280
|
+
props.onSent?.(email);
|
|
1281
|
+
}
|
|
1282
|
+
catch (err) {
|
|
1283
|
+
setError(err instanceof Error ? err.message : "Couldn't send verification.");
|
|
1284
|
+
}
|
|
1285
|
+
finally {
|
|
1286
|
+
setBusy(false);
|
|
1287
|
+
}
|
|
1288
|
+
}, [client, email, props, sessionToken]);
|
|
1289
|
+
if (sent) {
|
|
1290
|
+
return (_jsx("div", { className: props.className, children: _jsxs("p", { children: [_jsx("strong", { children: "Check your inbox." }), " A verification link is on the way to ", email, ". It expires in 15 minutes."] }) }));
|
|
1291
|
+
}
|
|
1292
|
+
return (_jsxs("div", { className: props.className, children: [_jsxs("label", { style: { display: "block", marginBottom: 8 }, children: ["Email for shipping notifications", _jsx("input", { autoComplete: "email", onChange: (e) => setEmail(e.target.value), placeholder: "you@example.com", style: { display: "block", width: "100%", padding: 8 }, type: "email", value: email })] }), error ? (_jsx("p", { style: { color: "#dc2626", margin: "4px 0" }, children: error })) : null, _jsx("button", { disabled: busy || !email.includes("@"), onClick: handleSend, type: "button", children: busy ? "Sending…" : "Send verification link" })] }));
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Deep-link target for an email verification flow. Reads the `token`
|
|
1296
|
+
* prop (typically pulled from the URL query string), confirms it
|
|
1297
|
+
* server-side, and renders success / failure.
|
|
1298
|
+
*/
|
|
1299
|
+
export function VerifyEmailConfirm(props) {
|
|
1300
|
+
const { apiKey, baseUrl, sessionToken, token, onVerified } = props;
|
|
1301
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
1302
|
+
const [state, setState] = useState({ kind: "idle" });
|
|
1303
|
+
useEffect(() => {
|
|
1304
|
+
let cancelled = false;
|
|
1305
|
+
(async () => {
|
|
1306
|
+
try {
|
|
1307
|
+
const r = await client.notifications
|
|
1308
|
+
.withSession(sessionToken)
|
|
1309
|
+
.confirmVerification({ token });
|
|
1310
|
+
if (cancelled)
|
|
1311
|
+
return;
|
|
1312
|
+
setState({ kind: "ok", destination: r.destination });
|
|
1313
|
+
onVerified?.(r.destination);
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
if (cancelled)
|
|
1317
|
+
return;
|
|
1318
|
+
setState({
|
|
1319
|
+
kind: "err",
|
|
1320
|
+
message: err instanceof Error ? err.message : "Verification failed.",
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
})();
|
|
1324
|
+
return () => {
|
|
1325
|
+
cancelled = true;
|
|
1326
|
+
};
|
|
1327
|
+
}, [client, onVerified, sessionToken, token]);
|
|
1328
|
+
if (state.kind === "idle")
|
|
1329
|
+
return _jsx("div", { className: props.className, children: "Verifying\u2026" });
|
|
1330
|
+
if (state.kind === "err") {
|
|
1331
|
+
return (_jsxs("div", { className: props.className, children: [_jsxs("p", { style: { color: "#dc2626" }, children: [_jsx("strong", { children: "Couldn't verify." }), " ", state.message] }), _jsx("p", { children: "The link may have expired. Request a new one from your notification preferences page." })] }));
|
|
1332
|
+
}
|
|
1333
|
+
return (_jsx("div", { className: props.className, children: _jsxs("p", { children: [_jsx("strong", { children: "Verified \u2713" }), " Notifications will now send to", " ", state.destination, "."] }) }));
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Inline SMS-OTP verification flow. Two phases, no round-trip
|
|
1337
|
+
* deep link. 6-digit OTP, 5-minute TTL.
|
|
1338
|
+
*/
|
|
1339
|
+
export function VerifyPhoneFlow(props) {
|
|
1340
|
+
const { apiKey, baseUrl, sessionToken, currentPhone, onVerified } = props;
|
|
1341
|
+
const client = useMemo(() => new BiabDevClient({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
1342
|
+
const [phone, setPhone] = useState(currentPhone ?? "");
|
|
1343
|
+
const [phase, setPhase] = useState("enter");
|
|
1344
|
+
const [otp, setOtp] = useState("");
|
|
1345
|
+
const [busy, setBusy] = useState(false);
|
|
1346
|
+
const [error, setError] = useState(null);
|
|
1347
|
+
const handleSend = useCallback(async () => {
|
|
1348
|
+
setError(null);
|
|
1349
|
+
setBusy(true);
|
|
1350
|
+
try {
|
|
1351
|
+
await client.notifications
|
|
1352
|
+
.withSession(sessionToken)
|
|
1353
|
+
.startVerification({ kind: "phone", destination: phone });
|
|
1354
|
+
setPhase("otp");
|
|
1355
|
+
}
|
|
1356
|
+
catch (err) {
|
|
1357
|
+
setError(err instanceof Error ? err.message : "Couldn't send code.");
|
|
1358
|
+
}
|
|
1359
|
+
finally {
|
|
1360
|
+
setBusy(false);
|
|
1361
|
+
}
|
|
1362
|
+
}, [client, phone, sessionToken]);
|
|
1363
|
+
const handleConfirm = useCallback(async () => {
|
|
1364
|
+
setError(null);
|
|
1365
|
+
setBusy(true);
|
|
1366
|
+
try {
|
|
1367
|
+
const r = await client.notifications
|
|
1368
|
+
.withSession(sessionToken)
|
|
1369
|
+
.confirmVerification({ token: otp });
|
|
1370
|
+
setPhase("done");
|
|
1371
|
+
onVerified?.(r.destination);
|
|
1372
|
+
}
|
|
1373
|
+
catch (err) {
|
|
1374
|
+
setError(err instanceof Error ? err.message : "Code didn't match.");
|
|
1375
|
+
}
|
|
1376
|
+
finally {
|
|
1377
|
+
setBusy(false);
|
|
1378
|
+
}
|
|
1379
|
+
}, [client, onVerified, otp, sessionToken]);
|
|
1380
|
+
if (phase === "done") {
|
|
1381
|
+
return (_jsx("div", { className: props.className, children: _jsxs("p", { children: [_jsx("strong", { children: "Verified \u2713" }), " SMS notifications will now go to ", phone, "."] }) }));
|
|
1382
|
+
}
|
|
1383
|
+
if (phase === "otp") {
|
|
1384
|
+
return (_jsxs("div", { className: props.className, children: [_jsxs("p", { children: ["We sent a 6-digit code to ", phone, ". Enter it below."] }), _jsx("input", { autoComplete: "one-time-code", inputMode: "numeric", maxLength: 6, onChange: (e) => setOtp(e.target.value.replace(/\D/g, "")), placeholder: "123456", style: {
|
|
1385
|
+
display: "block",
|
|
1386
|
+
width: "100%",
|
|
1387
|
+
padding: 8,
|
|
1388
|
+
letterSpacing: 4,
|
|
1389
|
+
fontSize: 18,
|
|
1390
|
+
}, type: "text", value: otp }), error ? (_jsx("p", { style: { color: "#dc2626", margin: "4px 0" }, children: error })) : null, _jsx("button", { disabled: busy || otp.length !== 6, onClick: handleConfirm, type: "button", children: busy ? "Verifying…" : "Confirm" })] }));
|
|
1391
|
+
}
|
|
1392
|
+
return (_jsxs("div", { className: props.className, children: [_jsxs("label", { style: { display: "block", marginBottom: 8 }, children: ["Phone number for SMS notifications", _jsx("input", { autoComplete: "tel", onChange: (e) => setPhone(e.target.value), placeholder: "+1 415 555 0123", style: { display: "block", width: "100%", padding: 8 }, type: "tel", value: phone })] }), error ? (_jsx("p", { style: { color: "#dc2626", margin: "4px 0" }, children: error })) : null, _jsx("button", { disabled: busy || phone.length < 7, onClick: handleSend, type: "button", children: busy ? "Sending…" : "Send code" })] }));
|
|
1393
|
+
}
|
|
1394
|
+
// ─── Element rendering ─────────────────────────────────────────────────────
|
|
1395
|
+
// Search thresholds and icon-URL resolution live in `forms-core/elements` so
|
|
1396
|
+
// every binding agrees; only the view layer is per-framework.
|
|
1397
|
+
/**
|
|
1398
|
+
* `icon_select` — pick an icon by name from a searchable grid.
|
|
1399
|
+
*
|
|
1400
|
+
* The name list is fetched lazily from the platform on first render rather than
|
|
1401
|
+
* bundled: it's ~1,600 entries that change only when Lucide does. An author who
|
|
1402
|
+
* curated `allowedIcons` skips the fetch entirely.
|
|
1403
|
+
*/
|
|
1404
|
+
function BiabIconSelect({ field, value, set, }) {
|
|
1405
|
+
const cfg = field
|
|
1406
|
+
.iconConfig ?? {};
|
|
1407
|
+
const curated = cfg.allowedIcons ?? [];
|
|
1408
|
+
const [all, setAll] = useState(curated);
|
|
1409
|
+
const [query, setQuery] = useState("");
|
|
1410
|
+
const selected = typeof value === "string" ? value : "";
|
|
1411
|
+
useEffect(() => {
|
|
1412
|
+
if (curated.length > 0)
|
|
1413
|
+
return; // nothing to fetch
|
|
1414
|
+
let cancelled = false;
|
|
1415
|
+
// Resolves to [] on failure rather than throwing — an empty grid is
|
|
1416
|
+
// recoverable, a crashed form isn't.
|
|
1417
|
+
void fetchIconNames().then((names) => {
|
|
1418
|
+
if (!cancelled)
|
|
1419
|
+
setAll(names);
|
|
1420
|
+
});
|
|
1421
|
+
return () => {
|
|
1422
|
+
cancelled = true;
|
|
1423
|
+
};
|
|
1424
|
+
}, [curated.length]);
|
|
1425
|
+
const results = useMemo(() => filterIconNames(all, query), [all, query]);
|
|
1426
|
+
return (_jsxs("div", { className: "biab-iconselect", children: [_jsx("input", { className: "biab-input biab-iconselect__search", onChange: (e) => setQuery(e.target.value), placeholder: field.placeholder || "Search icons…", type: "text", value: query }), selected && (_jsxs("span", { className: "biab-iconselect__current", children: [_jsx("img", { alt: selected, height: 16, src: iconUrl(selected), width: 16 }), _jsx("button", { className: "biab-iconselect__clear", onClick: () => set(""), type: "button", children: "\u00D7" })] })), _jsx("div", { className: "biab-iconselect__grid", children: results.map((name) => (_jsx("button", { "aria-label": name, "aria-pressed": selected === name, className: `biab-iconselect__item${selected === name ? "is-selected" : ""}`, onClick: () => set(name), title: name, type: "button", children: _jsx("img", { alt: "", height: 16, src: iconUrl(name), width: 16 }) }, name))) })] }));
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* `image_input` — one image, from an upload OR a pasted URL.
|
|
1430
|
+
*
|
|
1431
|
+
* The submitted value is a plain URL string either way. An upload is held as a
|
|
1432
|
+
* pending file and flushed by the controller at submit, exactly like
|
|
1433
|
+
* `file_upload`, then replaced by its stored URL.
|
|
1434
|
+
*/
|
|
1435
|
+
function BiabImageInput({ field, value, set, }) {
|
|
1436
|
+
const cfg = field.imageInputConfig ?? {};
|
|
1437
|
+
const sources = cfg.sources ?? "both";
|
|
1438
|
+
const [mode, setMode] = useState(sources === "url" ? "url" : "upload");
|
|
1439
|
+
const pending = isPendingFileValue(value) ? value : null;
|
|
1440
|
+
const url = typeof value === "string" ? value : "";
|
|
1441
|
+
return (_jsxs("div", { className: "biab-imageinput", children: [sources === "both" && (_jsx("div", { className: "biab-imageinput__modes", children: ["upload", "url"].map((m) => (_jsx("button", { "aria-pressed": mode === m, className: `biab-imageinput__mode${mode === m ? "is-active" : ""}`, onClick: () => setMode(m), type: "button", children: m === "upload" ? "Upload" : "Paste URL" }, m))) })), mode === "upload" && sources !== "url" ? (_jsx("input", { accept: (cfg.allowedTypes ?? ["image/*"]).join(","), className: "biab-input biab-imageinput__file", onChange: (e) => {
|
|
1442
|
+
const file = e.target.files?.[0];
|
|
1443
|
+
if (!file)
|
|
1444
|
+
return;
|
|
1445
|
+
const maxBytes = (cfg.maxSize ?? 5) * 1024 * 1024;
|
|
1446
|
+
if (file.size > maxBytes)
|
|
1447
|
+
return;
|
|
1448
|
+
set({
|
|
1449
|
+
pending: true,
|
|
1450
|
+
file,
|
|
1451
|
+
name: file.name,
|
|
1452
|
+
size: file.size,
|
|
1453
|
+
});
|
|
1454
|
+
}, type: "file" })) : (_jsx("input", { className: "biab-input biab-imageinput__url", onChange: (e) => set(e.target.value), placeholder: field.placeholder || "https://example.com/image.png", type: "url", value: url })), cfg.showPreview !== false && (pending || url) ? (_jsx("img", { alt: "", className: "biab-imageinput__preview", src: pending ? "" : url })) : null, pending && (_jsxs("span", { className: "biab-imageinput__pending", children: [pending.name, " \u2014 uploads on submit."] }))] }));
|
|
1455
|
+
}
|
|
1456
|
+
// A `BiabFormProvider` lets a consumer supply the client once at the root and
|
|
1457
|
+
// drop `<BiabForm slug="…" />` anywhere below without threading the client prop.
|
|
1458
|
+
const BiabFormClientContext = createContext(null);
|
|
1459
|
+
/** Production package-API base URL — the default target for a publishable token
|
|
1460
|
+
* when no `baseUrl` is supplied. Override via the `baseUrl` prop for staging or
|
|
1461
|
+
* a custom host. Use the **apex** host: `www.biab.app` 307-redirects to the
|
|
1462
|
+
* apex, and a CORS preflight does not follow redirects (the browser fails the
|
|
1463
|
+
* request), so a browser publishable-token client must target the apex. */
|
|
1464
|
+
export const DEFAULT_PACKAGE_BASE_URL = "https://biab.app/api/package/v1";
|
|
1465
|
+
/** Provide a BIAB forms client to every nested `<BiabForm>`. Pass a `client`, or
|
|
1466
|
+
* a publishable `token` + `siteId` (+ optional `baseUrl`) to build a browser
|
|
1467
|
+
* client. */
|
|
1468
|
+
export function BiabFormProvider({ client, token, siteId, baseUrl, children, }) {
|
|
1469
|
+
const resolved = useMemo(() => client ??
|
|
1470
|
+
(token && siteId
|
|
1471
|
+
? createBiabClient({
|
|
1472
|
+
apiKey: token,
|
|
1473
|
+
siteId,
|
|
1474
|
+
baseUrl: baseUrl ?? DEFAULT_PACKAGE_BASE_URL,
|
|
1475
|
+
})
|
|
1476
|
+
: null), [client, token, siteId, baseUrl]);
|
|
1477
|
+
return (_jsx(BiabFormClientContext.Provider, { value: resolved, children: children }));
|
|
1478
|
+
}
|
|
1479
|
+
/** Read the client from a surrounding `<BiabFormProvider>`, if any. */
|
|
1480
|
+
export function useBiabFormsClient() {
|
|
1481
|
+
return useContext(BiabFormClientContext);
|
|
1482
|
+
}
|
|
1483
|
+
const FetchScheduleSlotsContext = createContext(undefined);
|
|
1484
|
+
/** Autofill (`autocomplete`) config for the field tree, provided by `<BiabForm>`.
|
|
1485
|
+
* Default `{}` → inference on, no overrides. */
|
|
1486
|
+
const AutocompleteContext = createContext({});
|
|
1487
|
+
/** Auto-schedule (instant booking) picker for `<BiabForm>`: fetches the assigned
|
|
1488
|
+
* staff's open slots and lets the visitor pick one. Value `{ slotStartAt,
|
|
1489
|
+
* timezone }`; the server books the meeting on submit. Dependency-light (plain
|
|
1490
|
+
* elements + `biab-field__schedule-*` classes). */
|
|
1491
|
+
function BiabScheduleAuto({ field, value, onChange, }) {
|
|
1492
|
+
const fetchSlots = useContext(FetchScheduleSlotsContext);
|
|
1493
|
+
const v = value && typeof value === "object" ? value : {};
|
|
1494
|
+
const tz = typeof Intl !== "undefined"
|
|
1495
|
+
? (Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC")
|
|
1496
|
+
: "UTC";
|
|
1497
|
+
const [slots, setSlots] = useState([]);
|
|
1498
|
+
const [phase, setPhase] = useState("idle");
|
|
1499
|
+
useEffect(() => {
|
|
1500
|
+
if (!fetchSlots)
|
|
1501
|
+
return;
|
|
1502
|
+
let cancelled = false;
|
|
1503
|
+
setPhase("loading");
|
|
1504
|
+
const from = new Date();
|
|
1505
|
+
const to = new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
|
|
1506
|
+
fetchSlots({ fieldId: field.id, from, to })
|
|
1507
|
+
.then((res) => {
|
|
1508
|
+
if (cancelled)
|
|
1509
|
+
return;
|
|
1510
|
+
setSlots(res);
|
|
1511
|
+
setPhase("loaded");
|
|
1512
|
+
})
|
|
1513
|
+
.catch(() => {
|
|
1514
|
+
if (!cancelled)
|
|
1515
|
+
setPhase("error");
|
|
1516
|
+
});
|
|
1517
|
+
return () => {
|
|
1518
|
+
cancelled = true;
|
|
1519
|
+
};
|
|
1520
|
+
}, [fetchSlots, field.id]);
|
|
1521
|
+
if (!fetchSlots) {
|
|
1522
|
+
return (_jsx("p", { className: "biab-field__schedule-hint", children: "Booking isn't available here." }));
|
|
1523
|
+
}
|
|
1524
|
+
if (phase === "loading" || phase === "idle") {
|
|
1525
|
+
return (_jsx("p", { className: "biab-field__schedule-hint", children: "Loading available times\u2026" }));
|
|
1526
|
+
}
|
|
1527
|
+
if (phase === "error") {
|
|
1528
|
+
return (_jsx("p", { className: "biab-field__schedule-hint", children: "Couldn't load times right now." }));
|
|
1529
|
+
}
|
|
1530
|
+
if (slots.length === 0) {
|
|
1531
|
+
return (_jsx("p", { className: "biab-field__schedule-hint", children: "No open times in the next few weeks." }));
|
|
1532
|
+
}
|
|
1533
|
+
const byDay = new Map();
|
|
1534
|
+
for (const s of slots) {
|
|
1535
|
+
const d = new Date(s.startAt);
|
|
1536
|
+
const dayKey = d.toLocaleDateString(undefined, {
|
|
1537
|
+
weekday: "short",
|
|
1538
|
+
month: "short",
|
|
1539
|
+
day: "numeric",
|
|
1540
|
+
});
|
|
1541
|
+
const label = d.toLocaleTimeString(undefined, {
|
|
1542
|
+
hour: "numeric",
|
|
1543
|
+
minute: "2-digit",
|
|
1544
|
+
});
|
|
1545
|
+
const arr = byDay.get(dayKey) ?? [];
|
|
1546
|
+
arr.push({ startAt: s.startAt, label });
|
|
1547
|
+
byDay.set(dayKey, arr);
|
|
1548
|
+
}
|
|
1549
|
+
return (_jsx("div", { className: "biab-field__schedule-auto", children: [...byDay.entries()].map(([day, times]) => (_jsxs("div", { className: "biab-field__schedule-day", children: [_jsx("p", { className: "biab-field__schedule-day-label", children: day }), _jsx("div", { className: "biab-field__schedule-times", children: times.map((t) => (_jsx("button", { className: "biab-field__schedule-slot", "data-selected": v.slotStartAt === t.startAt ? "true" : undefined, onClick: () => onChange({ slotStartAt: t.startAt, timezone: tz }), type: "button", children: t.label }, t.startAt))) })] }, day))) }));
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Subscribe a component to a `FormController` via React's official external-store
|
|
1553
|
+
* primitive. `getSnapshot` returns the controller's cached snapshot object, which
|
|
1554
|
+
* the store only re-creates on mutation, so referential equality gives React a
|
|
1555
|
+
* correct bail-out without extra memoization. SSR uses the same snapshot.
|
|
1556
|
+
*/
|
|
1557
|
+
function useFormSnapshot(controller) {
|
|
1558
|
+
const subscribe = useCallback((onStoreChange) => controller.subscribe(() => onStoreChange()), [controller]);
|
|
1559
|
+
const getSnapshot = useCallback(() => controller.snapshot(), [controller]);
|
|
1560
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
1561
|
+
}
|
|
1562
|
+
// ── biab-* class helpers (mirrors FormPreview's `fieldCssKey`) ───────────────
|
|
1563
|
+
function cx(...parts) {
|
|
1564
|
+
return parts.filter(Boolean).join(" ");
|
|
1565
|
+
}
|
|
1566
|
+
/** camelCase a label into a stable, override-friendly CSS key. */
|
|
1567
|
+
function toOutputKeyLite(label) {
|
|
1568
|
+
return label
|
|
1569
|
+
.trim()
|
|
1570
|
+
.toLowerCase()
|
|
1571
|
+
.replace(/[^a-z0-9]+(.)?/g, (_m, c) => c ? c.toUpperCase() : "");
|
|
1572
|
+
}
|
|
1573
|
+
function fieldCssKey(field) {
|
|
1574
|
+
const explicit = typeof field.outputKey === "string" ? field.outputKey.trim() : "";
|
|
1575
|
+
if (explicit)
|
|
1576
|
+
return explicit;
|
|
1577
|
+
const lbl = typeof field.label === "string" ? field.label.trim() : "";
|
|
1578
|
+
return (lbl && toOutputKeyLite(lbl)) || field.id;
|
|
1579
|
+
}
|
|
1580
|
+
/** Format a phone's local part as the user types and cap the digit count.
|
|
1581
|
+
* National (US/CA) → `(555) 123-4567` (10-digit cap). International → loose
|
|
1582
|
+
* 3-digit grouping preserving a leading `+` (15-digit E.164 cap). Idempotent,
|
|
1583
|
+
* so it's safe as a controlled value. The submitted value carries the
|
|
1584
|
+
* formatting; server + SDK validation strip non-digits, so it stays valid. */
|
|
1585
|
+
function formatPhoneLocal(input, intl) {
|
|
1586
|
+
if (intl) {
|
|
1587
|
+
const plus = input.trimStart().startsWith("+") ? "+" : "";
|
|
1588
|
+
const digits = input.replace(/\D/g, "").slice(0, 15);
|
|
1589
|
+
return `${plus}${digits.replace(/(\d{3})(?=\d)/g, "$1 ")}`.trim();
|
|
1590
|
+
}
|
|
1591
|
+
// National (US/CA) — mirrors the canonical formatPhoneNumber: bare digits up to
|
|
1592
|
+
// 3, `(555) 123` up to 6, then `(555) 123-4567` (display caps at 10 digits).
|
|
1593
|
+
const cleaned = input.replace(/\D/g, "");
|
|
1594
|
+
if (cleaned.length <= 3)
|
|
1595
|
+
return cleaned;
|
|
1596
|
+
if (cleaned.length <= 6)
|
|
1597
|
+
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
|
|
1598
|
+
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`;
|
|
1599
|
+
}
|
|
1600
|
+
/** Table "check" column cell → ✓ / – / plain text. Mirrors the app's copy in
|
|
1601
|
+
* `src/shared/forms/table-cell.ts`. */
|
|
1602
|
+
function tableCheckState(cell) {
|
|
1603
|
+
const v = cell.trim().toLowerCase();
|
|
1604
|
+
if (["yes", "y", "true", "1", "x", "✓", "✔", "check"].includes(v))
|
|
1605
|
+
return "yes";
|
|
1606
|
+
if (["no", "n", "false", "0", "-", "–", "—", ""].includes(v))
|
|
1607
|
+
return "no";
|
|
1608
|
+
return "text";
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Guard a user-supplied href before rendering it as a link: block dangerous
|
|
1612
|
+
* schemes (javascript:/data:/vbscript:/file:) that would be an XSS vector, while
|
|
1613
|
+
* allowing http(s) and relative paths. Returns undefined when unsafe so the
|
|
1614
|
+
* caller renders no link. (The SDK renders plain `<a>` — framework-agnostic, no
|
|
1615
|
+
* next/link — so this is the only hardening needed.)
|
|
1616
|
+
*/
|
|
1617
|
+
function safeHttpHref(raw) {
|
|
1618
|
+
if (!raw)
|
|
1619
|
+
return undefined;
|
|
1620
|
+
const s = raw.trim();
|
|
1621
|
+
if (!s)
|
|
1622
|
+
return undefined;
|
|
1623
|
+
const collapsed = s.toLowerCase().replace(/\s/g, "");
|
|
1624
|
+
if (collapsed.startsWith("javascript:") ||
|
|
1625
|
+
collapsed.startsWith("data:") ||
|
|
1626
|
+
collapsed.startsWith("vbscript:") ||
|
|
1627
|
+
collapsed.startsWith("file:")) {
|
|
1628
|
+
return undefined;
|
|
1629
|
+
}
|
|
1630
|
+
return s;
|
|
1631
|
+
}
|
|
1632
|
+
// ── Self-contained date-range picker (no external calendar dep) ─────────────
|
|
1633
|
+
// A popover trigger + a month grid with click-to-select range. Stored value is
|
|
1634
|
+
// `{ start, end }` as `yyyy-MM-dd` strings (identical to the app's contract).
|
|
1635
|
+
// Styled via biab-* classes so consumers can theme it.
|
|
1636
|
+
function parseIsoDate(s) {
|
|
1637
|
+
if (!s)
|
|
1638
|
+
return null;
|
|
1639
|
+
const d = new Date(`${s}T00:00:00`);
|
|
1640
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
1641
|
+
}
|
|
1642
|
+
function toIsoDate(d) {
|
|
1643
|
+
const y = d.getFullYear();
|
|
1644
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
1645
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
1646
|
+
return `${y}-${m}-${day}`;
|
|
1647
|
+
}
|
|
1648
|
+
const BIAB_MONTHS = [
|
|
1649
|
+
"January",
|
|
1650
|
+
"February",
|
|
1651
|
+
"March",
|
|
1652
|
+
"April",
|
|
1653
|
+
"May",
|
|
1654
|
+
"June",
|
|
1655
|
+
"July",
|
|
1656
|
+
"August",
|
|
1657
|
+
"September",
|
|
1658
|
+
"October",
|
|
1659
|
+
"November",
|
|
1660
|
+
"December",
|
|
1661
|
+
];
|
|
1662
|
+
const BIAB_WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
|
1663
|
+
function BiabDateRange({ value, setValue, }) {
|
|
1664
|
+
const v = (value && typeof value === "object" ? value : {});
|
|
1665
|
+
const start = parseIsoDate(v.start);
|
|
1666
|
+
const end = parseIsoDate(v.end);
|
|
1667
|
+
const [open, setOpen] = useState(false);
|
|
1668
|
+
const [view, setView] = useState(() => {
|
|
1669
|
+
const base = start ?? new Date();
|
|
1670
|
+
return { y: base.getFullYear(), m: base.getMonth() };
|
|
1671
|
+
});
|
|
1672
|
+
const ref = useRef(null);
|
|
1673
|
+
useEffect(() => {
|
|
1674
|
+
if (!open)
|
|
1675
|
+
return;
|
|
1676
|
+
const onDoc = (e) => {
|
|
1677
|
+
if (ref.current && !ref.current.contains(e.target)) {
|
|
1678
|
+
setOpen(false);
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
document.addEventListener("mousedown", onDoc);
|
|
1682
|
+
return () => document.removeEventListener("mousedown", onDoc);
|
|
1683
|
+
}, [open]);
|
|
1684
|
+
const pick = (day) => {
|
|
1685
|
+
const iso = toIsoDate(day);
|
|
1686
|
+
// First click (or restart after a complete range) sets the start.
|
|
1687
|
+
if (!start || (start && end)) {
|
|
1688
|
+
setValue({ start: iso, end: "" });
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
// Second click: order the two so start <= end (`start` is non-null here).
|
|
1692
|
+
if (day < start)
|
|
1693
|
+
setValue({ start: iso, end: toIsoDate(start) });
|
|
1694
|
+
else
|
|
1695
|
+
setValue({ start: toIsoDate(start), end: iso });
|
|
1696
|
+
};
|
|
1697
|
+
const firstDow = new Date(view.y, view.m, 1).getDay();
|
|
1698
|
+
const daysInMonth = new Date(view.y, view.m + 1, 0).getDate();
|
|
1699
|
+
const cells = [];
|
|
1700
|
+
for (let i = 0; i < firstDow; i++)
|
|
1701
|
+
cells.push(null);
|
|
1702
|
+
for (let d = 1; d <= daysInMonth; d++)
|
|
1703
|
+
cells.push(new Date(view.y, view.m, d));
|
|
1704
|
+
const sameDay = (a, b) => toIsoDate(a) === toIsoDate(b);
|
|
1705
|
+
const today = new Date();
|
|
1706
|
+
// COSS/shadcn range-state flags (drive the data-* attrs the CSS targets).
|
|
1707
|
+
const dayState = (d) => ({
|
|
1708
|
+
start: !!start && sameDay(d, start),
|
|
1709
|
+
end: !!end && sameDay(d, end),
|
|
1710
|
+
middle: !!start && !!end && d > start && d < end,
|
|
1711
|
+
single: !!start && !end && sameDay(d, start),
|
|
1712
|
+
today: sameDay(d, today),
|
|
1713
|
+
});
|
|
1714
|
+
const label = start
|
|
1715
|
+
? end
|
|
1716
|
+
? `${v.start} → ${v.end}`
|
|
1717
|
+
: `${v.start} → …`
|
|
1718
|
+
: "Pick a date range";
|
|
1719
|
+
// Positioning is the only inline style (so the popover never overlaps content
|
|
1720
|
+
// even before the CSS loads); everything visual lives in `biab-forms.css`,
|
|
1721
|
+
// which mirrors the COSS/shadcn calendar (import it from the SDK).
|
|
1722
|
+
return (_jsxs("div", { className: "biab-daterange", ref: ref, style: { position: "relative" }, children: [_jsx("button", { className: "biab-input biab-field__input biab-daterange__trigger", onClick: () => setOpen((o) => !o), type: "button", children: label }), open && (_jsxs("div", { className: "biab-daterange__popover", style: { position: "absolute", zIndex: 50 }, children: [_jsxs("div", { className: "biab-daterange__nav", children: [_jsx("button", { "aria-label": "Previous month", className: "biab-daterange__navbtn", onClick: () => setView((s) => s.m === 0 ? { y: s.y - 1, m: 11 } : { y: s.y, m: s.m - 1 }), type: "button", children: "\u2039" }), _jsxs("span", { className: "biab-daterange__title", children: [BIAB_MONTHS[view.m], " ", view.y] }), _jsx("button", { "aria-label": "Next month", className: "biab-daterange__navbtn", onClick: () => setView((s) => s.m === 11 ? { y: s.y + 1, m: 0 } : { y: s.y, m: s.m + 1 }), type: "button", children: "\u203A" })] }), _jsxs("div", { className: "biab-daterange__grid", children: [BIAB_WEEKDAYS.map((w) => (_jsx("span", { className: "biab-daterange__dow", children: w }, w))), cells.map((d, i) => {
|
|
1723
|
+
if (!d) {
|
|
1724
|
+
return (_jsx("span", { className: "biab-daterange__blank" }, `b-${i}`));
|
|
1725
|
+
}
|
|
1726
|
+
const st = dayState(d);
|
|
1727
|
+
return (_jsx("button", { className: "biab-daterange__day", "data-range-end": st.end || undefined, "data-range-middle": st.middle || undefined, "data-range-start": st.start || undefined, "data-selected-single": st.single || undefined, "data-today": st.today || undefined, onClick: () => pick(d), type: "button", children: d.getDate() }, toIsoDate(d)));
|
|
1728
|
+
})] })] }))] }));
|
|
1729
|
+
}
|
|
1730
|
+
// ── Per-type input renderer (light DOM, biab-* classes, no shadcn) ──────────
|
|
1731
|
+
function BiabFieldInput({ field, value, setValue, }) {
|
|
1732
|
+
const set = (v) => setValue(field.id, v);
|
|
1733
|
+
const ph = field.placeholder || "";
|
|
1734
|
+
// Resolved HTML autofill token for this field (or undefined to omit the
|
|
1735
|
+
// attribute). Honors the global `<BiabForm autoComplete>` switch and per-field
|
|
1736
|
+
// overrides; see forms-core/autocomplete.ts.
|
|
1737
|
+
const acConfig = useContext(AutocompleteContext);
|
|
1738
|
+
const autoComplete = resolveFieldAutocomplete(field, acConfig);
|
|
1739
|
+
switch (field.type) {
|
|
1740
|
+
case "title":
|
|
1741
|
+
return _jsx("h2", { className: "biab-field__title", children: field.label || "Untitled" });
|
|
1742
|
+
case "subtitle":
|
|
1743
|
+
return _jsx("h3", { className: "biab-field__subtitle", children: field.label || "" });
|
|
1744
|
+
case "separator":
|
|
1745
|
+
return _jsx("hr", { className: "biab-field__separator" });
|
|
1746
|
+
case "richtext":
|
|
1747
|
+
// `content` is server-sanitized HTML.
|
|
1748
|
+
return field.content ? (_jsx("div", { className: "biab-field__richtext",
|
|
1749
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: server-sanitized
|
|
1750
|
+
dangerouslySetInnerHTML: { __html: field.content } })) : null;
|
|
1751
|
+
case "body_text":
|
|
1752
|
+
// Plain body paragraph (no HTML). Display only.
|
|
1753
|
+
return field.content ? (_jsx("p", { className: "biab-field__body", children: field.content })) : null;
|
|
1754
|
+
case "bullet_list": {
|
|
1755
|
+
const lc = field.listConfig ?? {};
|
|
1756
|
+
const variant = lc.variant ?? "bullets";
|
|
1757
|
+
const items = lc.items ?? [];
|
|
1758
|
+
if (variant === "details") {
|
|
1759
|
+
return (_jsx("div", { className: "biab-field__points", children: items.map((it, i) => (_jsxs("div", { className: "biab-field__point", children: [it.icon && (_jsx("span", { "aria-hidden": "true", className: "biab-field__point-icon", "data-icon": it.icon })), _jsxs("span", { className: "biab-field__point-body", children: [_jsx("span", { className: "biab-field__point-title", children: it.text }), it.description && (_jsx("span", { className: "biab-field__point-desc", children: it.description }))] })] }, `${field.id}-li-${i}`))) }));
|
|
1760
|
+
}
|
|
1761
|
+
return (_jsxs("div", { className: "biab-field__bullets", children: [lc.title && (_jsx("div", { className: "biab-field__bullets-title", children: lc.title })), _jsx("ul", { children: items.map((it, i) => (_jsx("li", { children: it.text }, `${field.id}-li-${i}`))) })] }));
|
|
1762
|
+
}
|
|
1763
|
+
case "table": {
|
|
1764
|
+
const cfg = field.tableConfig ?? {};
|
|
1765
|
+
const columns = cfg.columns ?? [];
|
|
1766
|
+
const rows = cfg.rows ?? [];
|
|
1767
|
+
if (columns.length === 0)
|
|
1768
|
+
return null;
|
|
1769
|
+
return (_jsx("div", { className: "biab-field__table", children: _jsxs("table", { children: [_jsx("thead", { children: _jsx("tr", { children: columns.map((col, ci) => (_jsx("th", { "data-align": col.align ?? "left", "data-highlight": cfg.highlightColumn === ci ? "" : undefined, children: col.header }, `${field.id}-col-${ci}`))) }) }), _jsx("tbody", { children: rows.map((row, ri) => (_jsx("tr", { children: columns.map((col, ci) => {
|
|
1770
|
+
const cell = row.cells?.[ci] ?? "";
|
|
1771
|
+
const check = col.type === "check" ? tableCheckState(cell) : "text";
|
|
1772
|
+
return (_jsx("td", { "data-align": col.align ?? "left", "data-check": check === "text" ? undefined : check, "data-highlight": cfg.highlightColumn === ci ? "" : undefined, children: check === "yes" ? "✓" : check === "no" ? "–" : cell }, `${field.id}-cell-${ri}-${ci}`));
|
|
1773
|
+
}) }, `${field.id}-row-${ri}`))) })] }) }));
|
|
1774
|
+
}
|
|
1775
|
+
case "calculation": {
|
|
1776
|
+
// Value is computed by the controller (non-sensitive) or returned by
|
|
1777
|
+
// the server after submit (sensitive). Read-only display.
|
|
1778
|
+
const cfg = field
|
|
1779
|
+
.calculationConfig;
|
|
1780
|
+
const hasVal = value !== null && value !== undefined && value !== "";
|
|
1781
|
+
if (cfg?.sensitive && !hasVal) {
|
|
1782
|
+
return (_jsx("output", { className: "biab-field__calc biab-field__calc--pending", children: "Calculated on submit." }));
|
|
1783
|
+
}
|
|
1784
|
+
return (_jsx("output", { className: "biab-field__calc", children: hasVal ? formatCalcValue(value, cfg?.display) : "—" }));
|
|
1785
|
+
}
|
|
1786
|
+
case "image": {
|
|
1787
|
+
const cfg = (field["imageConfig"] ?? {});
|
|
1788
|
+
return cfg.src ? (_jsx("img", { alt: cfg.alt || "", className: "biab-field__image", src: cfg.src, style: {
|
|
1789
|
+
width: cfg.width ? `${cfg.width}px` : "auto",
|
|
1790
|
+
height: cfg.height ? `${cfg.height}px` : "auto",
|
|
1791
|
+
maxWidth: "100%",
|
|
1792
|
+
} })) : null;
|
|
1793
|
+
}
|
|
1794
|
+
case "textarea": {
|
|
1795
|
+
const cfg = (field["textareaConfig"] ?? {});
|
|
1796
|
+
return (_jsx("textarea", { autoComplete: autoComplete, className: "biab-textarea biab-field__input", onChange: (e) => set(e.target.value), placeholder: ph, rows: cfg.rows ?? 4, style: cfg.resizable
|
|
1797
|
+
? { resize: "vertical", minHeight: "6rem", maxHeight: "30rem" }
|
|
1798
|
+
: { resize: "none" }, value: typeof value === "string" ? value : "" }));
|
|
1799
|
+
}
|
|
1800
|
+
case "select": {
|
|
1801
|
+
const opts = field.options ?? [];
|
|
1802
|
+
// ComboBox card (`display: "combobox"`), or any list long enough that
|
|
1803
|
+
// scrolling it is the worse experience. `<input list>` + `<datalist>`
|
|
1804
|
+
// gives native typeahead with no JS and no styling opinion — which is
|
|
1805
|
+
// exactly the SDK's contract.
|
|
1806
|
+
if (shouldSearchOptions(opts.length, field.display)) {
|
|
1807
|
+
const listId = `biab-list-${field.id}`;
|
|
1808
|
+
return (_jsxs(_Fragment, { children: [_jsx("input", { className: "biab-input biab-combobox biab-field__input", list: listId, onChange: (e) => set(e.target.value), placeholder: ph || "Search…", type: "text", value: typeof value === "string" ? value : "" }), _jsx("datalist", { id: listId, children: opts.map((opt) => (_jsx("option", { value: opt }, opt))) })] }));
|
|
1809
|
+
}
|
|
1810
|
+
return (_jsxs("select", { className: "biab-select biab-field__input", onChange: (e) => set(e.target.value), value: typeof value === "string" ? value : "", children: [_jsx("option", { value: "", children: ph || "Select…" }), opts.map((opt) => (_jsx("option", { value: opt, children: opt }, opt)))] }));
|
|
1811
|
+
}
|
|
1812
|
+
case "icon_select":
|
|
1813
|
+
return _jsx(BiabIconSelect, { field: field, set: set, value: value });
|
|
1814
|
+
case "image_input":
|
|
1815
|
+
return _jsx(BiabImageInput, { field: field, set: set, value: value });
|
|
1816
|
+
case "radio":
|
|
1817
|
+
return (_jsx("div", { className: "biab-field__radios", children: (field.options ?? []).map((opt) => (_jsxs("label", { className: "biab-field__radio", children: [_jsx("input", { checked: value === opt, name: field.id, onChange: () => set(opt), type: "radio", value: opt }), _jsx("span", { children: opt })] }, opt))) }));
|
|
1818
|
+
case "checkbox": {
|
|
1819
|
+
const arr = Array.isArray(value) ? value : [];
|
|
1820
|
+
// Multi-Select element: a checkbox (value = string[]) rendered as a native
|
|
1821
|
+
// multiple-select. The SDK ships unstyled HTML; consumers style it.
|
|
1822
|
+
if (field.display === "dropdown") {
|
|
1823
|
+
return (_jsx("select", { className: "biab-select biab-field__input", multiple: true, onChange: (e) => set(Array.from(e.target.selectedOptions).map((o) => o.value)), value: arr, children: (field.options ?? []).map((opt) => (_jsx("option", { value: opt, children: opt }, opt))) }));
|
|
1824
|
+
}
|
|
1825
|
+
return (_jsx("div", { className: "biab-field__checkboxes", children: (field.options ?? []).map((opt) => {
|
|
1826
|
+
const checked = arr.includes(opt);
|
|
1827
|
+
return (_jsxs("label", { className: "biab-field__checkbox", children: [_jsx("input", { checked: checked, onChange: (e) => set(e.target.checked
|
|
1828
|
+
? [...arr, opt]
|
|
1829
|
+
: arr.filter((x) => x !== opt)), type: "checkbox" }), _jsx("span", { children: opt })] }, opt));
|
|
1830
|
+
}) }));
|
|
1831
|
+
}
|
|
1832
|
+
case "choice_cards": {
|
|
1833
|
+
const cc = field.choiceConfig ?? {};
|
|
1834
|
+
const ccMulti = cc.mode === "multi";
|
|
1835
|
+
const ccOptions = cc.options ?? [];
|
|
1836
|
+
const arr = ccMulti && Array.isArray(value) ? value : [];
|
|
1837
|
+
const single = !ccMulti && typeof value === "string" ? value : "";
|
|
1838
|
+
const toggle = (label) => {
|
|
1839
|
+
if (ccMulti) {
|
|
1840
|
+
set(arr.includes(label)
|
|
1841
|
+
? arr.filter((x) => x !== label)
|
|
1842
|
+
: [...arr, label]);
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
set(single === label ? "" : label);
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
return (_jsx("div", { className: "biab-field__choice-cards", role: ccMulti ? "group" : "radiogroup", children: ccOptions.map((opt) => {
|
|
1849
|
+
const active = ccMulti
|
|
1850
|
+
? arr.includes(opt.label)
|
|
1851
|
+
: single === opt.label;
|
|
1852
|
+
return (_jsxs("button", { "aria-pressed": active, className: "biab-field__choice-card", "data-active": active ? "" : undefined, "data-product-id": opt.productId || undefined, onClick: () => toggle(opt.label), role: ccMulti ? "checkbox" : "radio", type: "button", children: [opt.imageUrl ? (_jsx("img", { alt: opt.label, className: "biab-field__choice-card-image", src: opt.imageUrl })) : opt.icon ? (_jsx("span", { "aria-hidden": "true", className: "biab-field__choice-card-icon", "data-icon": opt.icon })) : null, _jsxs("span", { className: "biab-field__choice-card-body", children: [_jsxs("span", { className: "biab-field__choice-card-header", children: [_jsx("span", { className: "biab-field__choice-card-label", children: opt.label }), opt.price ? (_jsx("span", { className: "biab-field__choice-card-price", children: opt.price })) : null] }), opt.description ? (_jsx("span", { className: "biab-field__choice-card-description", children: opt.description })) : null, safeHttpHref(opt.href) ? (_jsx("a", { className: "biab-field__choice-card-link", href: safeHttpHref(opt.href), onClick: (e) => e.stopPropagation(), rel: "noopener noreferrer nofollow", target: "_blank", children: "View" })) : null] })] }, opt.label));
|
|
1853
|
+
}) }));
|
|
1854
|
+
}
|
|
1855
|
+
case "boolean":
|
|
1856
|
+
return (_jsxs("label", { className: "biab-field__switch", children: [_jsx("input", { checked: value === true, onChange: (e) => set(e.target.checked), type: "checkbox" }), _jsx("span", { children: field.placeholder || "Yes / No" })] }));
|
|
1857
|
+
case "date":
|
|
1858
|
+
return (_jsx("input", { className: "biab-input biab-field__input", onChange: (e) => set(e.target.value), type: "date", value: typeof value === "string" ? value : "" }));
|
|
1859
|
+
case "time_picker": {
|
|
1860
|
+
const mode = field.timeConfig?.mode ?? "simple";
|
|
1861
|
+
if (mode === "simple") {
|
|
1862
|
+
const format = field.timeConfig?.format ?? "12h";
|
|
1863
|
+
const step = Math.max(field.timeConfig?.stepMinutes ?? 30, 1);
|
|
1864
|
+
const opts = [];
|
|
1865
|
+
for (let h = 0; h < 24; h++) {
|
|
1866
|
+
for (let m = 0; m < 60; m += step) {
|
|
1867
|
+
opts.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const fmt = (t) => {
|
|
1871
|
+
if (format === "24h")
|
|
1872
|
+
return t;
|
|
1873
|
+
const [hStr, mStr = "00"] = t.split(":");
|
|
1874
|
+
const h = Number(hStr);
|
|
1875
|
+
const ampm = h < 12 ? "AM" : "PM";
|
|
1876
|
+
const h12 = h % 12 === 0 ? 12 : h % 12;
|
|
1877
|
+
return `${h12}:${mStr} ${ampm}`;
|
|
1878
|
+
};
|
|
1879
|
+
return (_jsxs("select", { className: "biab-select biab-field__input", onChange: (e) => set(e.target.value), value: typeof value === "string" ? value : "", children: [_jsx("option", { value: "", children: ph || "Select time" }), opts.map((o) => (_jsx("option", { value: o, children: fmt(o) }, o)))] }));
|
|
1880
|
+
}
|
|
1881
|
+
const pair = mode === "time_block"
|
|
1882
|
+
? ["start", "end"]
|
|
1883
|
+
: ["date", "time"];
|
|
1884
|
+
const v = value && typeof value === "object"
|
|
1885
|
+
? value
|
|
1886
|
+
: {};
|
|
1887
|
+
const inputType = (k) => (k === "date" ? "date" : "time");
|
|
1888
|
+
return (_jsx("div", { className: "biab-field__grid2", children: pair.map((k) => (_jsxs("label", { className: "biab-field__sub", children: [_jsxs("span", { className: "biab-field__sublabel", children: [k[0]?.toUpperCase(), k.slice(1)] }), _jsx("input", { className: "biab-input", onChange: (e) => set({ ...v, [k]: e.target.value }), type: inputType(k), value: v[k] ?? "" })] }, k))) }));
|
|
1889
|
+
}
|
|
1890
|
+
case "date_range":
|
|
1891
|
+
return _jsx(BiabDateRange, { setValue: (next) => set(next), value: value });
|
|
1892
|
+
case "slider": {
|
|
1893
|
+
const min = field.sliderConfig?.min ?? 0;
|
|
1894
|
+
const max = field.sliderConfig?.max ?? 100;
|
|
1895
|
+
const step = field.sliderConfig?.step ?? 1;
|
|
1896
|
+
const unit = field.sliderConfig?.unit;
|
|
1897
|
+
const fmt = (n) => unit === "currency" ? `$${n}` : unit === "percent" ? `${n}%` : `${n}`;
|
|
1898
|
+
const v = typeof value === "number" ? value : min;
|
|
1899
|
+
return (_jsxs("div", { className: "biab-field__slider", children: [_jsxs("div", { className: "biab-field__sliderscale", children: [_jsx("span", { children: fmt(min) }), _jsx("span", { className: "biab-field__slidervalue", children: fmt(v) }), _jsx("span", { children: fmt(max) })] }), _jsx("input", { max: max, min: min, onChange: (e) => set(Number(e.target.value)), step: step, type: "range", value: v })] }));
|
|
1900
|
+
}
|
|
1901
|
+
case "star_rating": {
|
|
1902
|
+
const max = field.starRatingConfig?.maxStars ?? 5;
|
|
1903
|
+
const v = typeof value === "number" ? value : 0;
|
|
1904
|
+
return (_jsx("div", { className: "biab-field__stars", children: Array.from({ length: max }).map((_, i) => (_jsx("button", { "aria-label": `${i + 1} star${i ? "s" : ""}`, className: cx("biab-field__star", i < v && "biab-field__star--on"), onClick: () => set(i + 1), type: "button", children: "\u2605" }, `${field.id}-star-${i}`))) }));
|
|
1905
|
+
}
|
|
1906
|
+
case "happiness_rating": {
|
|
1907
|
+
const v = typeof value === "number" ? value : -1;
|
|
1908
|
+
return (_jsx("div", { className: "biab-field__happiness", children: ["😞", "😕", "😐", "🙂", "😄"].map((emoji, i) => (_jsx("button", { className: cx("biab-field__emoji", i === v && "biab-field__emoji--on"), onClick: () => set(i), type: "button", children: emoji }, emoji))) }));
|
|
1909
|
+
}
|
|
1910
|
+
case "stepper": {
|
|
1911
|
+
const v = typeof value === "number" ? value : 0;
|
|
1912
|
+
return (_jsxs("div", { className: "biab-field__stepper", children: [_jsx("button", { className: "biab-btn biab-btn--ghost", onClick: () => set(v - 1), type: "button", children: "\u2212" }), _jsx("input", { className: "biab-field__steppervalue", inputMode: "numeric", onChange: (e) => {
|
|
1913
|
+
const n = Number(e.target.value.replace(/[^\d-]/g, ""));
|
|
1914
|
+
set(Number.isFinite(n) ? n : 0);
|
|
1915
|
+
}, value: String(v) }), _jsx("button", { className: "biab-btn biab-btn--ghost", onClick: () => set(v + 1), type: "button", children: "+" })] }));
|
|
1916
|
+
}
|
|
1917
|
+
case "address": {
|
|
1918
|
+
const v = value && typeof value === "object"
|
|
1919
|
+
? value
|
|
1920
|
+
: {};
|
|
1921
|
+
// Per-subfield autofill tokens (street/city/state/zip), suppressed when
|
|
1922
|
+
// autofill is off for this field (global switch or per-field override).
|
|
1923
|
+
const acOff = autoComplete === "off";
|
|
1924
|
+
const rows = [
|
|
1925
|
+
{ k: "line1", label: "Address Line 1", ac: "street-address" },
|
|
1926
|
+
{ k: "line2", label: "Address Line 2", ac: "address-line2" },
|
|
1927
|
+
{ k: "city", label: "City", ac: "address-level2" },
|
|
1928
|
+
];
|
|
1929
|
+
return (_jsxs("div", { className: "biab-field__address", children: [rows.map(({ k, label, ac }) => (_jsxs("label", { className: "biab-field__sub", children: [_jsx("span", { className: "biab-field__sublabel", children: label }), _jsx("input", { autoComplete: acOff ? "off" : ac, className: "biab-input", onChange: (e) => set({ ...v, [k]: e.target.value }), value: v[k] ?? "" })] }, k))), _jsxs("label", { className: "biab-field__sub", children: [_jsx("span", { className: "biab-field__sublabel", children: "State" }), _jsxs("select", { autoComplete: acOff ? "off" : "address-level1", className: "biab-select", onChange: (e) => set({ ...v, state: e.target.value }), value: v["state"] ?? "", children: [_jsx("option", { value: "", children: "Select state" }), US_STATES.map((s) => (_jsx("option", { value: s.abbr, children: s.name }, s.abbr)))] })] }, "state"), _jsxs("label", { className: "biab-field__sub", children: [_jsx("span", { className: "biab-field__sublabel", children: "Zip" }), _jsx("input", { autoComplete: acOff ? "off" : "postal-code", className: "biab-input", inputMode: "numeric", onChange: (e) =>
|
|
1930
|
+
// Numbers only (allow a single hyphen for ZIP+4).
|
|
1931
|
+
set({ ...v, zip: e.target.value.replace(/[^\d-]/g, "") }), placeholder: "90210", value: v["zip"] ?? "" })] }, "zip")] }));
|
|
1932
|
+
}
|
|
1933
|
+
case "color_picker": {
|
|
1934
|
+
const v = typeof value === "string" ? value : "";
|
|
1935
|
+
const presets = field.options ?? [];
|
|
1936
|
+
const isCustom = v !== "" && !presets.includes(v);
|
|
1937
|
+
return (_jsxs("div", { className: "biab-field__colors", children: [presets.map((color) => (_jsx("button", { className: cx("biab-field__color", v === color && "biab-field__color--on"), onClick: () => set(color), style: { backgroundColor: color }, title: color, type: "button" }, color))), _jsx("label", { className: cx("biab-field__color biab-field__color--custom", isCustom && "biab-field__color--on"), style: isCustom
|
|
1938
|
+
? { backgroundColor: v }
|
|
1939
|
+
: {
|
|
1940
|
+
background: "conic-gradient(red, yellow, lime, cyan, blue, magenta, red)",
|
|
1941
|
+
}, title: isCustom ? v : "Custom color", children: _jsx("input", { "aria-label": "Custom color", onChange: (e) => set(e.target.value), style: { position: "absolute", inset: 0, opacity: 0 }, type: "color", value: isCustom ? v : "#3b82f6" }) })] }));
|
|
1942
|
+
}
|
|
1943
|
+
case "file_upload": {
|
|
1944
|
+
// DEFERRED: selecting buffers a PendingFileValue; the controller uploads
|
|
1945
|
+
// it during submit(). Validation runs up front; the server re-checks on
|
|
1946
|
+
// presign.
|
|
1947
|
+
const files = Array.isArray(value)
|
|
1948
|
+
? value
|
|
1949
|
+
: [];
|
|
1950
|
+
const cfg = field.fileConfig ?? {};
|
|
1951
|
+
const maxFiles = cfg.maxFiles ?? 1;
|
|
1952
|
+
const remaining = Math.max(maxFiles - files.length, 0);
|
|
1953
|
+
return (_jsxs("div", { className: "biab-field__files", children: [_jsx("input", { accept: buildAcceptAttr(cfg.allowedTypes), className: "biab-field__fileinput", disabled: remaining === 0, multiple: maxFiles > 1, onChange: (e) => {
|
|
1954
|
+
const list = e.target.files;
|
|
1955
|
+
if (!list)
|
|
1956
|
+
return;
|
|
1957
|
+
const additions = [];
|
|
1958
|
+
for (const file of Array.from(list).slice(0, remaining)) {
|
|
1959
|
+
if (validateFile(file, {
|
|
1960
|
+
allowedTypes: cfg.allowedTypes,
|
|
1961
|
+
maxSize: cfg.maxSize,
|
|
1962
|
+
})) {
|
|
1963
|
+
continue;
|
|
1964
|
+
}
|
|
1965
|
+
additions.push({
|
|
1966
|
+
pending: true,
|
|
1967
|
+
file,
|
|
1968
|
+
name: file.name,
|
|
1969
|
+
size: file.size,
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
if (additions.length > 0)
|
|
1973
|
+
set([...files, ...additions]);
|
|
1974
|
+
e.target.value = "";
|
|
1975
|
+
}, type: "file" }), files.length > 0 && (_jsx("ul", { className: "biab-field__filelist", children: files.map((f, i) => (_jsxs("li", { className: "biab-field__fileitem", children: [_jsx("span", { className: "biab-field__filename", children: f.name }), isPendingFileValue(f) ? (_jsxs("span", { className: "biab-field__filepending", children: [" ", "\u00B7 uploads on submit"] })) : null, _jsx("button", { "aria-label": "Remove file", className: "biab-field__fileremove", onClick: () => set(files.filter((_, idx) => idx !== i)), type: "button", children: "\u00D7" })] }, `${f.name}-${i}`))) }))] }));
|
|
1976
|
+
}
|
|
1977
|
+
case "credit_card": {
|
|
1978
|
+
const pc = field.paymentConfig;
|
|
1979
|
+
const deposit = pc?.mode === "deposit";
|
|
1980
|
+
const amount = deposit && pc?.amountCents
|
|
1981
|
+
? `${(pc.amountCents / 100).toFixed(2)} ${(pc.currency ?? "usd").toUpperCase()}`
|
|
1982
|
+
: null;
|
|
1983
|
+
return (_jsxs("div", { className: "biab-field__card", children: [_jsxs("div", { className: "biab-field__card-row", children: [_jsx("span", { className: "biab-field__card-brand", children: "CARD" }), _jsx("span", { className: "biab-field__card-dots", children: "\u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022" }), _jsx("span", { className: "biab-field__card-mode", children: deposit
|
|
1984
|
+
? `Deposit${amount ? ` · ${amount}` : ""}`
|
|
1985
|
+
: "Card on file" })] }), pc?.description ? (_jsx("p", { className: "biab-field__card-desc", children: pc.description })) : null, _jsxs("p", { className: "biab-field__card-note", children: ["Secure card entry via Stripe. Initialize Elements with", " ", _jsxs("code", { children: ["biab.forms.paymentIntent(slug, \"", field.id, "\")"] }), " (returns the clientSecret + the org's connected account), confirm, then store the intent id on this field before submit."] })] }));
|
|
1986
|
+
}
|
|
1987
|
+
case "schedule": {
|
|
1988
|
+
if (field.scheduleConfig
|
|
1989
|
+
?.mode === "auto") {
|
|
1990
|
+
return _jsx(BiabScheduleAuto, { field: field, onChange: set, value: value });
|
|
1991
|
+
}
|
|
1992
|
+
const sc = field.scheduleConfig;
|
|
1993
|
+
const sv = (value && typeof value === "object" ? value : {});
|
|
1994
|
+
const days = sc?.days?.length
|
|
1995
|
+
? sc.days
|
|
1996
|
+
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
1997
|
+
const windows = sc?.timeWindows?.length
|
|
1998
|
+
? sc.timeWindows
|
|
1999
|
+
: sc?.granularTimes
|
|
2000
|
+
? [
|
|
2001
|
+
"morning_8_10",
|
|
2002
|
+
"morning_10_12",
|
|
2003
|
+
"afternoon_12_2",
|
|
2004
|
+
"afternoon_2_4",
|
|
2005
|
+
"evening_4_6",
|
|
2006
|
+
"evening_6_8",
|
|
2007
|
+
]
|
|
2008
|
+
: ["morning", "afternoon", "evening"];
|
|
2009
|
+
const DAY = {
|
|
2010
|
+
mon: "Mon",
|
|
2011
|
+
tue: "Tue",
|
|
2012
|
+
wed: "Wed",
|
|
2013
|
+
thu: "Thu",
|
|
2014
|
+
fri: "Fri",
|
|
2015
|
+
sat: "Sat",
|
|
2016
|
+
sun: "Sun",
|
|
2017
|
+
};
|
|
2018
|
+
const TIME = {
|
|
2019
|
+
morning: "Morning",
|
|
2020
|
+
afternoon: "Afternoon",
|
|
2021
|
+
evening: "Evening",
|
|
2022
|
+
morning_8_10: "8–10 AM",
|
|
2023
|
+
morning_10_12: "10 AM–12 PM",
|
|
2024
|
+
afternoon_12_2: "12–2 PM",
|
|
2025
|
+
afternoon_2_4: "2–4 PM",
|
|
2026
|
+
evening_4_6: "4–6 PM",
|
|
2027
|
+
evening_6_8: "6–8 PM",
|
|
2028
|
+
};
|
|
2029
|
+
const toggle = (key, item) => {
|
|
2030
|
+
const arr = sv[key] ?? [];
|
|
2031
|
+
set({
|
|
2032
|
+
...sv,
|
|
2033
|
+
[key]: arr.includes(item)
|
|
2034
|
+
? arr.filter((x) => x !== item)
|
|
2035
|
+
: [...arr, item],
|
|
2036
|
+
});
|
|
2037
|
+
};
|
|
2038
|
+
return (_jsxs("div", { className: "biab-field__schedule", children: [sc?.allowAsap !== false && (_jsxs("label", { className: "biab-field__schedule-asap", children: [_jsx("input", { checked: sv.asap === true, onChange: (e) => set({ ...sv, asap: e.target.checked }), type: "checkbox" }), " ", "As soon as possible"] })), _jsxs("div", { className: "biab-field__schedule-group", children: [_jsx("span", { className: "biab-field__schedule-label", children: "Preferred days" }), _jsx("div", { className: "biab-field__schedule-chips", children: days.map((d) => (_jsx("button", { className: cx("biab-field__chip", sv.days?.includes(d) && "biab-field__chip--on"), onClick: () => toggle("days", d), type: "button", children: DAY[d] ?? d }, d))) })] }), _jsxs("div", { className: "biab-field__schedule-group", children: [_jsx("span", { className: "biab-field__schedule-label", children: "Preferred times" }), _jsx("div", { className: "biab-field__schedule-chips", children: windows.map((w) => (_jsx("button", { className: cx("biab-field__chip", sv.times?.includes(w) && "biab-field__chip--on"), onClick: () => toggle("times", w), type: "button", children: TIME[w] ?? w }, w))) })] }), sc?.allowNotes !== false && (_jsx("textarea", { className: "biab-input biab-field__schedule-notes", onChange: (e) => set({ ...sv, notes: e.target.value }), placeholder: "Anything we should know about timing?", value: sv.notes ?? "" }))] }));
|
|
2039
|
+
}
|
|
2040
|
+
case "phone": {
|
|
2041
|
+
const phoneIntl = field.phoneConfig?.international === true;
|
|
2042
|
+
const phoneShown = formatPhoneLocal(typeof value === "string" ? value : "", phoneIntl);
|
|
2043
|
+
return (_jsxs("div", { className: "biab-field__phone", children: [field.phoneConfig?.showFlags && (_jsx("span", { "aria-hidden": true, className: "biab-field__flag", children: "\uD83C\uDDFA\uD83C\uDDF8" })), _jsx("input", { autoComplete: autoComplete, className: "biab-input biab-field__input", inputMode: "tel", maxLength: phoneIntl ? 20 : 14, onChange: (e) => set(formatPhoneLocal(e.target.value, phoneIntl)), placeholder: ph || (phoneIntl ? "+1 (555) 000-0000" : "(555) 000-0000"), type: "tel", value: phoneShown })] }));
|
|
2044
|
+
}
|
|
2045
|
+
case "email":
|
|
2046
|
+
return (_jsx("input", { autoComplete: autoComplete, className: "biab-input biab-field__input", onChange: (e) => set(e.target.value), placeholder: ph || "you@example.com", type: "email", value: typeof value === "string" ? value : "" }));
|
|
2047
|
+
case "url": {
|
|
2048
|
+
// Value object: { url, verified }. Visitor edits `url`; server stamps
|
|
2049
|
+
// `verified` on submit.
|
|
2050
|
+
const uv = (value && typeof value === "object" ? value : {});
|
|
2051
|
+
return (_jsx("input", { autoComplete: autoComplete, className: "biab-input biab-field__input", inputMode: "url", onChange: (e) => set({ ...uv, url: e.target.value }), placeholder: ph || "https://example.com", type: "url", value: typeof uv.url === "string" ? uv.url : "" }));
|
|
2052
|
+
}
|
|
2053
|
+
case "number": {
|
|
2054
|
+
const allowDecimal = field.allowDecimal !== false;
|
|
2055
|
+
const noNegative = field.allowNegative === false;
|
|
2056
|
+
return (_jsx("input", { className: "biab-input biab-field__input", inputMode: allowDecimal ? "decimal" : "numeric", min: noNegative ? 0 : undefined, onChange: (e) => {
|
|
2057
|
+
const raw = e.target.value;
|
|
2058
|
+
if (raw === "")
|
|
2059
|
+
return set("");
|
|
2060
|
+
const n = allowDecimal ? Number(raw) : Math.trunc(Number(raw));
|
|
2061
|
+
set(Number.isNaN(n) ? "" : n);
|
|
2062
|
+
}, placeholder: ph, step: allowDecimal ? "any" : 1, type: "number", value: typeof value === "number"
|
|
2063
|
+
? value
|
|
2064
|
+
: typeof value === "string"
|
|
2065
|
+
? value
|
|
2066
|
+
: "" }));
|
|
2067
|
+
}
|
|
2068
|
+
case "currency": {
|
|
2069
|
+
const symbol = field.currencyConfig?.symbol || "$";
|
|
2070
|
+
const allowNeg = field.currencyConfig?.allowNegative === true;
|
|
2071
|
+
const allowDec = field.currencyConfig?.allowDecimal !== false;
|
|
2072
|
+
return (_jsxs("div", { className: "biab-field__currency", children: [_jsx("span", { "aria-hidden": "true", className: "biab-field__currency-symbol", children: symbol }), _jsx("input", { className: "biab-input biab-field__input", inputMode: "decimal", min: allowNeg ? undefined : 0, onChange: (e) => {
|
|
2073
|
+
const raw = e.target.value;
|
|
2074
|
+
if (raw === "")
|
|
2075
|
+
return set("");
|
|
2076
|
+
let n = allowDec ? Number(raw) : Math.trunc(Number(raw));
|
|
2077
|
+
if (Number.isNaN(n))
|
|
2078
|
+
return set("");
|
|
2079
|
+
if (!allowNeg && n < 0)
|
|
2080
|
+
n = Math.abs(n);
|
|
2081
|
+
set(n);
|
|
2082
|
+
}, placeholder: ph || "0.00", step: allowDec ? "0.01" : 1, type: "number", value: typeof value === "number" ? value : "" })] }));
|
|
2083
|
+
}
|
|
2084
|
+
case "measurement": {
|
|
2085
|
+
const cfg = field.unitConfig ?? {};
|
|
2086
|
+
const category = isMeasurementCategory(cfg.category)
|
|
2087
|
+
? cfg.category
|
|
2088
|
+
: "length";
|
|
2089
|
+
const units = cfg.units && cfg.units.length > 0
|
|
2090
|
+
? cfg.units
|
|
2091
|
+
: unitsForCategory(category);
|
|
2092
|
+
const mode = cfg.mode ?? "select";
|
|
2093
|
+
const defaultUnit = cfg.defaultUnit ?? units[0] ?? "";
|
|
2094
|
+
const allowDec = cfg.allowDecimal !== false;
|
|
2095
|
+
const mv = (value && typeof value === "object" ? value : {});
|
|
2096
|
+
const num = typeof mv.value === "number" ? mv.value : "";
|
|
2097
|
+
const unit = mv.unit ?? defaultUnit;
|
|
2098
|
+
const emit = (nextNum, nextUnit) => {
|
|
2099
|
+
const base = typeof nextNum === "number"
|
|
2100
|
+
? convertToBase(nextNum, nextUnit, category)
|
|
2101
|
+
: null;
|
|
2102
|
+
set({
|
|
2103
|
+
value: nextNum,
|
|
2104
|
+
unit: nextUnit,
|
|
2105
|
+
base: base ?? undefined,
|
|
2106
|
+
baseUnit: baseUnitFor(category),
|
|
2107
|
+
});
|
|
2108
|
+
};
|
|
2109
|
+
const hint = typeof num === "number" ? formatMeasurement(num, unit, category) : "";
|
|
2110
|
+
return (_jsxs("div", { className: "biab-field__measurement", children: [_jsx("input", { className: "biab-input biab-field__input", inputMode: "decimal", onChange: (e) => {
|
|
2111
|
+
const raw = e.target.value;
|
|
2112
|
+
if (raw === "")
|
|
2113
|
+
return emit("", unit);
|
|
2114
|
+
const n = allowDec ? Number(raw) : Math.trunc(Number(raw));
|
|
2115
|
+
emit(Number.isNaN(n) ? "" : n, unit);
|
|
2116
|
+
}, placeholder: ph || "0", step: allowDec ? "any" : 1, type: "number", value: num }), mode === "fixed" ? (_jsx("span", { "aria-hidden": "true", className: "biab-field__unit", children: unit })) : (_jsx("select", { className: "biab-select biab-field__unit-select", onChange: (e) => emit(num, e.target.value), value: unit, children: units.map((u) => (_jsx("option", { value: u, children: u }, u))) })), hint ? (_jsxs("span", { className: "biab-field__measurement-hint", children: ["\u2248 ", hint] })) : null] }));
|
|
2117
|
+
}
|
|
2118
|
+
default:
|
|
2119
|
+
return (_jsx("input", { autoComplete: autoComplete, className: "biab-input biab-field__input", onChange: (e) => set(e.target.value), placeholder: ph, value: typeof value === "string" ? value : "" }));
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
const NO_LABEL_TYPES = new Set([
|
|
2123
|
+
"title",
|
|
2124
|
+
"subtitle",
|
|
2125
|
+
"separator",
|
|
2126
|
+
"richtext",
|
|
2127
|
+
"body_text",
|
|
2128
|
+
"bullet_list",
|
|
2129
|
+
"table",
|
|
2130
|
+
]);
|
|
2131
|
+
function BiabFieldRow({ field, value, error, setValue, upload, }) {
|
|
2132
|
+
const showLabel = !NO_LABEL_TYPES.has(field.type);
|
|
2133
|
+
return (_jsxs("div", { className: cx("biab-field", `biab-field--${field.type}`, field.required && "biab-field--required", error && "biab-field--error"), "data-biab-field": fieldCssKey(field), "data-biab-type": field.type, "data-field-id": field.id, children: [showLabel && (
|
|
2134
|
+
// A visual field heading, not a control-associated <label> — the field's
|
|
2135
|
+
// inputs render in BiabFieldInput below and carry no shared id to point a
|
|
2136
|
+
// `htmlFor` at, so a <label> here would be an orphan.
|
|
2137
|
+
_jsxs("span", { className: "biab-field__label biab-label", children: [field.label || "", field.required && _jsx("span", { className: "biab-field__required", children: "*" })] })), _jsx(BiabFieldInput, { field: field, setValue: setValue, upload: upload, value: value }), error && _jsx("p", { className: "biab-field__error", children: error.message }), field.helpText && _jsx("p", { className: "biab-field__help", children: field.helpText })] }));
|
|
2138
|
+
}
|
|
2139
|
+
/** Recursively render a field subtree, mirroring FormPreview's `RenderTree`:
|
|
2140
|
+
* input_group (+ concurrent reveal), condition_block, or_condition, flex_row. */
|
|
2141
|
+
function BiabRenderTree({ fields, values, errors, setValue, upload, flat, }) {
|
|
2142
|
+
return (_jsx("div", { className: "biab-tree", children: fields.map((f) => {
|
|
2143
|
+
if (f.type === "multi_populator") {
|
|
2144
|
+
const cfg = f.populatorConfig ?? {};
|
|
2145
|
+
const max = cfg.maxInstances ?? 10;
|
|
2146
|
+
const children = (f.children ?? []);
|
|
2147
|
+
const raw = values[f.id];
|
|
2148
|
+
const instances = Array.isArray(raw) && raw.length > 0
|
|
2149
|
+
? raw
|
|
2150
|
+
: [{}];
|
|
2151
|
+
const writeInstances = (next) => setValue(f.id, next);
|
|
2152
|
+
return (_jsxs("div", { className: "biab-populator", "data-biab-field": fieldCssKey(f), "data-field-id": f.id, children: [instances.map((inst, idx) => {
|
|
2153
|
+
const scopedSetValue = (childId, v) => {
|
|
2154
|
+
const nextArr = instances.map((x) => ({ ...x }));
|
|
2155
|
+
nextArr[idx] = { ...nextArr[idx], [childId]: v };
|
|
2156
|
+
writeInstances(nextArr);
|
|
2157
|
+
};
|
|
2158
|
+
return (_jsxs("div", { className: "biab-populator__item", children: [_jsxs("div", { className: "biab-populator__item-head", children: [_jsxs("span", { className: "biab-populator__item-label", children: [cfg.itemLabel || f.label || "Entry", " ", idx + 1] }), instances.length > 1 && (_jsx("button", { "aria-label": "Remove entry", className: "biab-populator__remove", onClick: () => writeInstances(instances.filter((_, i) => i !== idx)), type: "button", children: "\u00D7" }))] }), _jsx(BiabRenderTree, { errors: errors, fields: children, setValue: scopedSetValue, upload: upload, values: inst })] }, `${f.id}-inst-${idx}`));
|
|
2159
|
+
}), instances.length < max && (_jsxs("button", { className: "biab-populator__add", onClick: () => writeInstances([...instances, {}]), type: "button", children: ["+ ", cfg.addButtonLabel || "Add another"] }))] }, f.id));
|
|
2160
|
+
}
|
|
2161
|
+
if (f.type === "input_group") {
|
|
2162
|
+
const children = (f.children ?? []);
|
|
2163
|
+
const concurrent = f.concurrentReveal === true;
|
|
2164
|
+
const showGroupLabel = f.showGroupLabel === true;
|
|
2165
|
+
const revealCount = concurrent
|
|
2166
|
+
? concurrentRevealCount(children, values)
|
|
2167
|
+
: children.length;
|
|
2168
|
+
const shown = concurrent ? children.slice(0, revealCount) : children;
|
|
2169
|
+
return (_jsxs("div", { className: cx("biab-group", flat && "biab-group--flat"), "data-biab-group": fieldCssKey(f), children: [f.label && showGroupLabel && (_jsx("div", { className: "biab-group__label", children: f.label })), _jsx(BiabRenderTree, { errors: errors, fields: shown, setValue: setValue, upload: upload, values: values })] }, f.id));
|
|
2170
|
+
}
|
|
2171
|
+
if (f.type === "condition_block") {
|
|
2172
|
+
if (!evalCondition(f.conditionRule, values))
|
|
2173
|
+
return null;
|
|
2174
|
+
return (_jsx(BiabRenderTree, { errors: errors, fields: (f.children ?? []), setValue: setValue, upload: upload, values: values }, f.id));
|
|
2175
|
+
}
|
|
2176
|
+
if (f.type === "or_condition") {
|
|
2177
|
+
const branches = f.children ?? [];
|
|
2178
|
+
const errored = Boolean(errors[f.id]);
|
|
2179
|
+
const branchLabel = (i) => (i === 0 && f.orConditionConfig?.leftLabel) ||
|
|
2180
|
+
(i === 1 && f.orConditionConfig?.rightLabel) ||
|
|
2181
|
+
branches[i]?.label ||
|
|
2182
|
+
["Option A", "Option B", "Option C"][i] ||
|
|
2183
|
+
`Option ${i + 1}`;
|
|
2184
|
+
return (_jsxs("div", { className: cx("biab-or", errored && "biab-or--error"), children: [f.label && (_jsxs("div", { className: "biab-or__label", children: [f.label, _jsx("span", { className: "biab-or__hint", children: "Fill at least one side" })] })), _jsx("div", { className: "biab-or__sides", children: branches.flatMap((child, i) => {
|
|
2185
|
+
const side = (_jsxs("div", { className: "biab-or__side", children: [_jsx("div", { className: "biab-or__sidelabel", children: branchLabel(i) }), _jsx(BiabRenderTree, { errors: errors, fields: [child], setValue: setValue, upload: upload, values: values })] }, child.id));
|
|
2186
|
+
if (i === 0)
|
|
2187
|
+
return [side];
|
|
2188
|
+
return [
|
|
2189
|
+
_jsx("div", { className: "biab-or__divider", children: "OR" }, `or-${child.id}`),
|
|
2190
|
+
side,
|
|
2191
|
+
];
|
|
2192
|
+
}) }), errored && (_jsx("p", { className: "biab-field__error", children: "Fill in at least one side to continue." }))] }, f.id));
|
|
2193
|
+
}
|
|
2194
|
+
if (f.type === "flex_row") {
|
|
2195
|
+
const cfg = (f["flexConfig"] ?? {});
|
|
2196
|
+
return (_jsx("div", { className: "biab-flexrow", style: {
|
|
2197
|
+
display: "flex",
|
|
2198
|
+
justifyContent: cfg.justify === "center"
|
|
2199
|
+
? "center"
|
|
2200
|
+
: cfg.justify === "end"
|
|
2201
|
+
? "flex-end"
|
|
2202
|
+
: cfg.justify === "between"
|
|
2203
|
+
? "space-between"
|
|
2204
|
+
: cfg.justify === "around"
|
|
2205
|
+
? "space-around"
|
|
2206
|
+
: "flex-start",
|
|
2207
|
+
alignItems: cfg.align === "start"
|
|
2208
|
+
? "flex-start"
|
|
2209
|
+
: cfg.align === "end"
|
|
2210
|
+
? "flex-end"
|
|
2211
|
+
: "center",
|
|
2212
|
+
gap: `${cfg.gap ?? 12}px`,
|
|
2213
|
+
flexWrap: "wrap",
|
|
2214
|
+
}, children: (f.children ?? []).map((child) => (_jsx("div", { className: "biab-flexrow__cell", children: _jsx(BiabRenderTree, { errors: errors, fields: [child], setValue: setValue, upload: upload, values: values }) }, child.id))) }, f.id));
|
|
2215
|
+
}
|
|
2216
|
+
return (_jsx(BiabFieldRow, { error: errors[f.id], field: f, setValue: setValue, upload: upload, value: values[f.id] }, f.id));
|
|
2217
|
+
}) }));
|
|
2218
|
+
}
|
|
2219
|
+
export function BiabForm(props) {
|
|
2220
|
+
const providerClient = useBiabFormsClient();
|
|
2221
|
+
// A publishable token (`pk_…`) builds a browser client that calls BIAB
|
|
2222
|
+
// directly — the zero-backend path. Explicit `client` still wins, then the
|
|
2223
|
+
// token client, then a surrounding provider.
|
|
2224
|
+
const tokenClient = useMemo(() => props.token && props.siteId
|
|
2225
|
+
? createBiabClient({
|
|
2226
|
+
apiKey: props.token,
|
|
2227
|
+
siteId: props.siteId,
|
|
2228
|
+
baseUrl: props.baseUrl ?? DEFAULT_PACKAGE_BASE_URL,
|
|
2229
|
+
})
|
|
2230
|
+
: null, [props.token, props.siteId, props.baseUrl]);
|
|
2231
|
+
const client = props.client ?? tokenClient ?? providerClient ?? undefined;
|
|
2232
|
+
const submitLabel = props.submitLabel ?? "Submit";
|
|
2233
|
+
// Latest callbacks without re-creating the controller on every render.
|
|
2234
|
+
const cbRef = useRef({
|
|
2235
|
+
onSuccess: props.onSuccess,
|
|
2236
|
+
onError: props.onError,
|
|
2237
|
+
onSubmit: props.onSubmit,
|
|
2238
|
+
});
|
|
2239
|
+
cbRef.current = {
|
|
2240
|
+
onSuccess: props.onSuccess,
|
|
2241
|
+
onError: props.onError,
|
|
2242
|
+
onSubmit: props.onSubmit,
|
|
2243
|
+
};
|
|
2244
|
+
// Serialize submitOptions so a fresh-but-equal object literal (the common
|
|
2245
|
+
// consumer case) doesn't recreate the controller and re-fetch every render.
|
|
2246
|
+
const submitOptionsKey = JSON.stringify(props.submitOptions ?? {});
|
|
2247
|
+
// One controller per (client, slug, schema, submit config). The controller is
|
|
2248
|
+
// the single source of truth; React only renders its snapshots. `submitOptions`
|
|
2249
|
+
// is keyed by its serialized value (`submitOptionsKey`); the spread reads the
|
|
2250
|
+
// live object, so the object identity itself is intentionally not a dep.
|
|
2251
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by submitOptionsKey
|
|
2252
|
+
const controller = useMemo(() => createFormController({
|
|
2253
|
+
...(client ? { client } : {}),
|
|
2254
|
+
...(props.slug ? { slug: props.slug } : {}),
|
|
2255
|
+
...(props.schema ? { schema: props.schema } : {}),
|
|
2256
|
+
submitOptions: {
|
|
2257
|
+
...props.submitOptions,
|
|
2258
|
+
...(props.dryRun ? { dryRun: true } : {}),
|
|
2259
|
+
},
|
|
2260
|
+
onSuccess: (r) => cbRef.current.onSuccess?.(r),
|
|
2261
|
+
onError: (r) => cbRef.current.onError?.(r),
|
|
2262
|
+
}), [client, props.slug, props.schema, props.dryRun, submitOptionsKey]);
|
|
2263
|
+
useEffect(() => {
|
|
2264
|
+
void controller.load();
|
|
2265
|
+
return () => controller.destroy();
|
|
2266
|
+
}, [controller]);
|
|
2267
|
+
const snap = useFormSnapshot(controller);
|
|
2268
|
+
const upload = useMemo(() => {
|
|
2269
|
+
if (!client?.forms.uploadFile)
|
|
2270
|
+
return undefined;
|
|
2271
|
+
return (fieldId, file) => controller.uploadFile(fieldId, file);
|
|
2272
|
+
}, [client, controller]);
|
|
2273
|
+
// Auto-`schedule` slot fetcher, derived from the client + slug and provided
|
|
2274
|
+
// via context so the deep field renderer needn't thread it.
|
|
2275
|
+
const fetchScheduleSlots = useMemo(() => {
|
|
2276
|
+
const slug = props.slug;
|
|
2277
|
+
const fn = client?.forms.scheduleSlots;
|
|
2278
|
+
if (!fn || !slug)
|
|
2279
|
+
return undefined;
|
|
2280
|
+
return (input) => fn(slug, input).then((r) => r.slots);
|
|
2281
|
+
}, [client, props.slug]);
|
|
2282
|
+
// Autofill config provided to the field tree. Keyed by the serialized
|
|
2283
|
+
// overrides so a fresh-but-equal `fieldAutoComplete` literal doesn't churn.
|
|
2284
|
+
const fieldAutoCompleteKey = JSON.stringify(props.fieldAutoComplete ?? null);
|
|
2285
|
+
const autocompleteConfig = useMemo(
|
|
2286
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by fieldAutoCompleteKey
|
|
2287
|
+
() => ({
|
|
2288
|
+
...(props.autoComplete !== undefined
|
|
2289
|
+
? { enabled: props.autoComplete }
|
|
2290
|
+
: {}),
|
|
2291
|
+
...(props.fieldAutoComplete
|
|
2292
|
+
? { overrides: props.fieldAutoComplete }
|
|
2293
|
+
: {}),
|
|
2294
|
+
}), [props.autoComplete, fieldAutoCompleteKey]);
|
|
2295
|
+
// Exit-confirm guard: warn before leaving a dirty, unsubmitted form when the
|
|
2296
|
+
// org enabled it. Browsers show a generic native prompt; the configured
|
|
2297
|
+
// message is also on `snap.settings.exitConfirm.message` for a custom modal.
|
|
2298
|
+
const exitConfirm = snap.settings?.exitConfirm;
|
|
2299
|
+
const exitDirty = Object.values(snap.values).some((v) => Array.isArray(v) ? v.length > 0 : v != null && v !== "" && v !== false);
|
|
2300
|
+
useEffect(() => {
|
|
2301
|
+
if (!exitConfirm?.enabled ||
|
|
2302
|
+
snap.submitted ||
|
|
2303
|
+
!exitDirty ||
|
|
2304
|
+
typeof window === "undefined") {
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
const onBeforeUnload = (e) => {
|
|
2308
|
+
e.preventDefault();
|
|
2309
|
+
e.returnValue = exitConfirm.message || "";
|
|
2310
|
+
};
|
|
2311
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
2312
|
+
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
2313
|
+
}, [exitConfirm?.enabled, exitConfirm?.message, snap.submitted, exitDirty]);
|
|
2314
|
+
const rootClass = cx("biab-form", props.className, props.class);
|
|
2315
|
+
if (snap.status === "loading" || snap.status === "idle") {
|
|
2316
|
+
return (_jsx("div", { className: rootClass, style: props.style, children: props.loadingFallback ?? (_jsx("div", { className: "biab-form__loading", children: "Loading\u2026" })) }));
|
|
2317
|
+
}
|
|
2318
|
+
if (snap.status === "error") {
|
|
2319
|
+
const message = snap.loadError ?? "Failed to load the form.";
|
|
2320
|
+
return (_jsx("div", { className: rootClass, style: props.style, children: props.errorFallback ? (props.errorFallback(message)) : (_jsx("div", { className: "biab-form__error", children: message })) }));
|
|
2321
|
+
}
|
|
2322
|
+
if (snap.submitted && snap.result?.ok) {
|
|
2323
|
+
const ok = snap.result;
|
|
2324
|
+
return (_jsx("div", { className: rootClass, style: props.style, children: props.successFallback ? (props.successFallback(ok)) : (_jsx("div", { className: "biab-form__success", children: "Thanks \u2014 your response was received." })) }));
|
|
2325
|
+
}
|
|
2326
|
+
const { settings } = snap;
|
|
2327
|
+
const consecutive = settings.isConsecutive && snap.stepCount > 1;
|
|
2328
|
+
const animClass = settings.animation === "fade"
|
|
2329
|
+
? "biab-anim--fade"
|
|
2330
|
+
: settings.animation === "scale"
|
|
2331
|
+
? "biab-anim--scale"
|
|
2332
|
+
: settings.animation === "slide"
|
|
2333
|
+
? snap.forward
|
|
2334
|
+
? "biab-anim--slide-fwd"
|
|
2335
|
+
: "biab-anim--slide-back"
|
|
2336
|
+
: settings.animation === "scroll"
|
|
2337
|
+
? "biab-anim--scroll"
|
|
2338
|
+
: "";
|
|
2339
|
+
const handleSubmit = async () => {
|
|
2340
|
+
const res = await controller.submit();
|
|
2341
|
+
if (res.ok) {
|
|
2342
|
+
cbRef.current.onSubmit?.(snap.values);
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
// Fix-it wizard: route to the first errored field's step (consecutive) and
|
|
2346
|
+
// scroll it into view, so cross-step / file-upload errors are never hidden.
|
|
2347
|
+
const firstId = res.issues?.[0]?.fieldId ?? snap.errorList[0]?.id;
|
|
2348
|
+
if (!firstId)
|
|
2349
|
+
return;
|
|
2350
|
+
let delay = 60;
|
|
2351
|
+
if (consecutive) {
|
|
2352
|
+
const si = snap.steps.findIndex((s) => flattenValueFields(s).some((f) => f.id === firstId));
|
|
2353
|
+
if (si >= 0 && si !== snap.step) {
|
|
2354
|
+
controller.goToStep(si);
|
|
2355
|
+
delay = 200; // let the destination step mount before scrolling
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
if (typeof document !== "undefined") {
|
|
2359
|
+
setTimeout(() => {
|
|
2360
|
+
const el = document.querySelector(`[data-field-id="${firstId}"]`);
|
|
2361
|
+
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
2362
|
+
}, delay);
|
|
2363
|
+
}
|
|
2364
|
+
};
|
|
2365
|
+
return (_jsx(FetchScheduleSlotsContext.Provider, { value: fetchScheduleSlots, children: _jsx(AutocompleteContext.Provider, { value: autocompleteConfig, children: _jsxs("div", { className: rootClass, style: props.style, children: [snap.orgIcon && (_jsx("img", { alt: "", className: "biab-form__orgicon", src: snap.orgIcon })), (snap.title || snap.description) && (_jsxs("div", { className: "biab-form__head", children: [snap.title && _jsx("h2", { className: "biab-form__title", children: snap.title }), snap.description && (_jsx("p", { className: "biab-form__desc", children: snap.description }))] })), consecutive &&
|
|
2366
|
+
settings.showProgress &&
|
|
2367
|
+
settings.progressStyle !== "bar" && (_jsx("div", { className: "biab-progress biab-progress--steps", children: snap.steps.map((s, i) => {
|
|
2368
|
+
const f = s[0];
|
|
2369
|
+
const title = f?.stepTitle || f?.label || `Step ${i + 1}`;
|
|
2370
|
+
const done = i < snap.step;
|
|
2371
|
+
const current = i === snap.step;
|
|
2372
|
+
return (_jsxs(Fragment, { children: [i > 0 && (_jsx("span", { className: cx("biab-progress__line", i <= snap.step && "biab-progress__line--done") })), _jsxs("button", { className: cx("biab-progress__step", current && "biab-progress__step--current", done && "biab-progress__step--done"), onClick: () => controller.goToStep(i), type: "button", children: [_jsx("span", { className: "biab-progress__num", children: done ? "✓" : i + 1 }), _jsx("span", { className: "biab-progress__title", children: title })] })] }, `step-${i}`));
|
|
2373
|
+
}) })), consecutive &&
|
|
2374
|
+
settings.showProgress &&
|
|
2375
|
+
settings.progressStyle === "bar" && (_jsxs("div", { className: "biab-progress biab-progress--bar", children: [_jsxs("span", { className: "biab-progress__count", children: [snap.step + 1, " / ", snap.stepCount] }), _jsx("div", { className: "biab-progress__track", children: _jsx("div", { className: "biab-progress__fill", style: { width: `${snap.progressPercent}%` } }) })] })), snap.errorList.length > 0 && (_jsxs("div", { className: "biab-form__notice", role: "alert", children: [_jsxs("div", { className: "biab-form__notice-title", children: ["Please complete the highlighted field", snap.errorList.length === 1 ? "" : "s", "."] }), _jsx("div", { className: "biab-form__notice-list", children: snap.errorList.map((e) => e.label).join(", ") })] })), _jsx("div", { className: cx("biab-form__body", animClass), children: _jsx(BiabRenderTree, { errors: snap.errors, fields: snap.visibleFields, flat: consecutive, setValue: (id, v) => controller.setValue(id, v), upload: upload, values: snap.values }) }, `biab-step-${snap.step}`), (!consecutive || snap.isLastStep) &&
|
|
2376
|
+
(snap.schema?.actions ?? [])
|
|
2377
|
+
.filter((a) => a.type === "grant_coupon" &&
|
|
2378
|
+
(a.coupon?.notice || a.coupon?.code))
|
|
2379
|
+
.map((a, i) => {
|
|
2380
|
+
const code = a.coupon?.code ?? "";
|
|
2381
|
+
const text = a.coupon?.notice
|
|
2382
|
+
? a.coupon.notice.replace(/\{code\}/g, code)
|
|
2383
|
+
: `Coupon ${code} will be applied to this submission.`;
|
|
2384
|
+
return (_jsxs("div", { className: "biab-coupon-notice", children: [_jsx("p", { className: "biab-coupon-notice__text", children: text }), a.coupon?.finePrint && (_jsx("p", { className: "biab-coupon-notice__fine", children: a.coupon.finePrint }))] }, `coupon-${i}`));
|
|
2385
|
+
}), consecutive ? (_jsxs("div", { className: "biab-nav", children: [_jsx("button", { className: "biab-btn biab-btn--back biab-btn--ghost", disabled: snap.isFirstStep, onClick: () => controller.back(), type: "button", children: "Back" }), !snap.isLastStep ? (_jsx("button", { className: "biab-btn biab-btn--next", disabled: !snap.canAdvance, onClick: () => controller.next(), type: "button", children: "Next" })) : (_jsx("button", { className: "biab-btn biab-btn--submit", disabled: snap.submitting || snap.uploading, onClick: () => void handleSubmit(), type: "button", children: snap.submitting ? "Sending…" : submitLabel }))] })) : (_jsx("div", { className: "biab-nav", children: _jsx("button", { className: "biab-btn biab-btn--submit", disabled: snap.submitting || snap.uploading, onClick: () => void handleSubmit(), type: "button", children: snap.submitting ? "Sending…" : submitLabel }) })), snap.result && !snap.result.ok && snap.errorList.length === 0 && (_jsx("div", { className: "biab-form__error", role: "alert", children: snap.result.message }))] }) }) }));
|
|
2386
|
+
}
|
|
2387
|
+
function SocialGlyph({ iconSlug, color, size, className, label, }) {
|
|
2388
|
+
if (iconSlug) {
|
|
2389
|
+
const src = color
|
|
2390
|
+
? `https://cdn.simpleicons.org/${iconSlug}/${encodeURIComponent(color)}`
|
|
2391
|
+
: `https://cdn.simpleicons.org/${iconSlug}`;
|
|
2392
|
+
return (
|
|
2393
|
+
// biome-ignore lint: remote brand icon from the simple-icons CDN
|
|
2394
|
+
_jsx("img", { alt: label, className: className, height: size, loading: "lazy", src: src, width: size }));
|
|
2395
|
+
}
|
|
2396
|
+
return (_jsxs("svg", { "aria-hidden": true, className: className, fill: "none", height: size, stroke: "currentColor", strokeWidth: 1.8, viewBox: "0 0 24 24", width: size, children: [_jsx("title", { children: label }), _jsx("circle", { cx: "12", cy: "12", r: "9" }), _jsx("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" })] }));
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Render every social profile on the company as an icon + link, in canonical
|
|
2400
|
+
* order, resolving BIAB's exact keys (`meta`, `twitterX`, …) for you. Branded
|
|
2401
|
+
* glyphs come from the simple-icons CDN; platforms without one fall back to a
|
|
2402
|
+
* globe. Returns `null` when no socials are set.
|
|
2403
|
+
*/
|
|
2404
|
+
export function SocialLinks({ socials, color, size = 20, className, linkClassName, iconClassName, }) {
|
|
2405
|
+
const rows = resolveSocialProfiles(socials);
|
|
2406
|
+
if (rows.length === 0)
|
|
2407
|
+
return null;
|
|
2408
|
+
return (_jsx("ul", { className: className, style: {
|
|
2409
|
+
display: "flex",
|
|
2410
|
+
flexWrap: "wrap",
|
|
2411
|
+
alignItems: "center",
|
|
2412
|
+
gap: "0.75rem",
|
|
2413
|
+
listStyle: "none",
|
|
2414
|
+
margin: 0,
|
|
2415
|
+
padding: 0,
|
|
2416
|
+
}, children: rows.map((row) => (_jsx("li", { children: _jsx("a", { "aria-label": row.label, className: linkClassName, href: row.url, rel: "noopener noreferrer", target: "_blank", title: row.label, children: _jsx(SocialGlyph, { className: iconClassName, color: color, iconSlug: row.iconSlug, label: row.label, size: size }) }) }, row.key))) }));
|
|
2417
|
+
}
|
|
2418
|
+
//# sourceMappingURL=react.js.map
|