@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/src/sdk.ts
ADDED
|
@@ -0,0 +1,1671 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level SDK surface — what the consumer actually writes against.
|
|
3
|
+
*
|
|
4
|
+
* The principle: the consumer authors design + granular data requests,
|
|
5
|
+
* and that's it. Auth, caching, revalidation, tagging, retries — all
|
|
6
|
+
* handled inside this module so the consumer code reads as:
|
|
7
|
+
*
|
|
8
|
+
* const items = await biab.gallery.list({
|
|
9
|
+
* limit: 12,
|
|
10
|
+
* fields: ["src", "title", "category", "blurDataURL"],
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* Auto-applied per request:
|
|
14
|
+
* • `Authorization: Bearer <apiKey>`
|
|
15
|
+
* • `next: { tags: ["biab:<resource>", "biab:site:<orgId-when-known>"],
|
|
16
|
+
* revalidate: <longBackstop> }` — invisible on non-Next
|
|
17
|
+
* runtimes that ignore the `next` key
|
|
18
|
+
* • Field selection forwarded as `?fields=…` so the platform
|
|
19
|
+
* SELECTs only what's needed.
|
|
20
|
+
*
|
|
21
|
+
* The companion `@businessdash/sdk/next/revalidate` route export closes
|
|
22
|
+
* the loop: when BIAB POSTs `content.published`, the route handler
|
|
23
|
+
* verifies the HMAC and calls `revalidateTag(...)` to bust the local
|
|
24
|
+
* Next cache. End-to-end, the consumer wrote ~5 lines.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
BiabDevAddressClient,
|
|
29
|
+
BiabDevBlogClient,
|
|
30
|
+
BiabDevCartClient,
|
|
31
|
+
BiabDevCheckoutClient,
|
|
32
|
+
BiabDevClient,
|
|
33
|
+
BiabDevCouponsClient,
|
|
34
|
+
BiabDevCustomerPortalClient,
|
|
35
|
+
BiabDevFollowersClient,
|
|
36
|
+
BiabDevMarketingClient,
|
|
37
|
+
BiabDevMarketingPagesClient,
|
|
38
|
+
BiabDevNotificationsClient,
|
|
39
|
+
BiabDevParallelPagesClient,
|
|
40
|
+
BiabDevReviewsClient,
|
|
41
|
+
BiabDevShippingClient,
|
|
42
|
+
BiabDevStorefrontClient,
|
|
43
|
+
BiabDevSubscriptionsClient,
|
|
44
|
+
} from "./client.js";
|
|
45
|
+
import {
|
|
46
|
+
getEmailDomain,
|
|
47
|
+
isConsumerEmailDomain,
|
|
48
|
+
} from "./forms-core/email-domains.js";
|
|
49
|
+
import type { BundleGalleryItem } from "./gallery-types.js";
|
|
50
|
+
|
|
51
|
+
// Apex host on purpose: www.biab.app 307-redirects to the apex and CORS
|
|
52
|
+
// preflight doesn't follow redirects, which breaks browser publishable-token
|
|
53
|
+
// clients. Server-side callers are unaffected, but one default keeps them aligned.
|
|
54
|
+
const DEFAULT_BASE_URL = "https://biab.app/api/package/v1";
|
|
55
|
+
const DEFAULT_REVALIDATE_SECONDS = 60 * 60 * 24; // 24h backstop
|
|
56
|
+
|
|
57
|
+
export interface CreateBiabClientOptions {
|
|
58
|
+
apiKey: string;
|
|
59
|
+
siteId: string;
|
|
60
|
+
baseUrl?: string;
|
|
61
|
+
/** Override the default 24h ISR backstop. */
|
|
62
|
+
revalidateSeconds?: number;
|
|
63
|
+
/** Custom fetch (testing, instrumentation, edge runtimes). */
|
|
64
|
+
fetch?: typeof fetch;
|
|
65
|
+
/**
|
|
66
|
+
* Optional `Origin` to forward — required when the API key has an
|
|
67
|
+
* Allowed Host configured on the platform (server-to-server fetch
|
|
68
|
+
* does not send `Origin` automatically).
|
|
69
|
+
*/
|
|
70
|
+
siteOrigin?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
|
74
|
+
|
|
75
|
+
interface InternalState {
|
|
76
|
+
apiKey: string;
|
|
77
|
+
siteId: string;
|
|
78
|
+
baseUrl: string;
|
|
79
|
+
revalidateSeconds: number;
|
|
80
|
+
fetch: Fetcher;
|
|
81
|
+
siteOrigin: string | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildHeaders(state: InternalState): Record<string, string> {
|
|
85
|
+
const headers: Record<string, string> = {
|
|
86
|
+
Authorization: `Bearer ${state.apiKey}`,
|
|
87
|
+
Accept: "application/json",
|
|
88
|
+
};
|
|
89
|
+
if (state.siteOrigin) headers["Origin"] = state.siteOrigin;
|
|
90
|
+
return headers;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildNextHint(tags: string[], seconds: number) {
|
|
94
|
+
// `next` is a Next.js-only field on RequestInit. Non-Next runtimes
|
|
95
|
+
// (Cloudflare Workers, Deno, raw Node fetch) ignore it cleanly.
|
|
96
|
+
return { tags, revalidate: seconds } as const;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function jsonGet<T>(
|
|
100
|
+
state: InternalState,
|
|
101
|
+
pathWithQuery: string,
|
|
102
|
+
tags: string[],
|
|
103
|
+
): Promise<T> {
|
|
104
|
+
const url = `${state.baseUrl.replace(/\/$/, "")}/${pathWithQuery.replace(/^\//, "")}`;
|
|
105
|
+
const init: RequestInit & { next?: unknown } = {
|
|
106
|
+
method: "GET",
|
|
107
|
+
headers: buildHeaders(state),
|
|
108
|
+
next: buildNextHint(tags, state.revalidateSeconds),
|
|
109
|
+
};
|
|
110
|
+
const res = await state.fetch(url, init);
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const text = await res.text().catch(() => "");
|
|
113
|
+
throw new Error(
|
|
114
|
+
`BIAB SDK ${res.status} ${res.statusText} on ${pathWithQuery}: ${text.slice(0, 200)}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return (await res.json()) as T;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Gallery resource
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export type GalleryField = keyof BundleGalleryItem;
|
|
125
|
+
|
|
126
|
+
export interface GalleryListParams<F extends readonly GalleryField[]> {
|
|
127
|
+
limit?: number;
|
|
128
|
+
fields?: F;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type GalleryItemFor<F extends readonly GalleryField[]> = {
|
|
132
|
+
[K in F[number]]: BundleGalleryItem[K];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export interface GalleryResource {
|
|
136
|
+
list<const F extends readonly GalleryField[]>(
|
|
137
|
+
params?: GalleryListParams<F>,
|
|
138
|
+
): Promise<GalleryItemFor<F>[]>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createGalleryResource(state: InternalState): GalleryResource {
|
|
142
|
+
return {
|
|
143
|
+
async list<const F extends readonly GalleryField[]>(
|
|
144
|
+
params?: GalleryListParams<F>,
|
|
145
|
+
): Promise<GalleryItemFor<F>[]> {
|
|
146
|
+
const query = new URLSearchParams();
|
|
147
|
+
if (params?.limit) query.set("limit", String(params.limit));
|
|
148
|
+
if (params?.fields && params.fields.length > 0) {
|
|
149
|
+
query.set("fields", params.fields.join(","));
|
|
150
|
+
}
|
|
151
|
+
const qs = query.toString();
|
|
152
|
+
const path = `sites/${encodeURIComponent(state.siteId)}/gallery${qs ? `?${qs}` : ""}`;
|
|
153
|
+
const tags = ["biab:gallery", `biab:site:${state.siteId}`];
|
|
154
|
+
// The server projects to exactly the requested `fields`, so the row
|
|
155
|
+
// shape is `GalleryItemFor<F>` — a fact only the wire can vouch for.
|
|
156
|
+
// Stating it as the fetch's type argument confines the assumption to
|
|
157
|
+
// this one boundary. The previous `as never` was strictly worse: it
|
|
158
|
+
// asserted the items were assignable to *anything*, so a genuine
|
|
159
|
+
// mismatch between projection and type would never have shown up.
|
|
160
|
+
const result = await jsonGet<{ items: GalleryItemFor<F>[] }>(
|
|
161
|
+
state,
|
|
162
|
+
path,
|
|
163
|
+
tags,
|
|
164
|
+
);
|
|
165
|
+
return result.items ?? [];
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Scheduling resource — full Calendly-shape booking flow
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Public-facing shape of an event type the consumer renders into a
|
|
176
|
+
* "book a meeting" page. Matches `/sites/{siteId}/scheduling/event-types`.
|
|
177
|
+
*/
|
|
178
|
+
export interface SchedulingEventType {
|
|
179
|
+
id: string;
|
|
180
|
+
name: string;
|
|
181
|
+
slug: string;
|
|
182
|
+
description: string | null;
|
|
183
|
+
durationMinutes: number;
|
|
184
|
+
locationType: string;
|
|
185
|
+
meetingProvider: string | null;
|
|
186
|
+
color: string | null;
|
|
187
|
+
requiresApproval: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface SchedulingQuestion {
|
|
191
|
+
id: string;
|
|
192
|
+
label: string;
|
|
193
|
+
fieldType: string;
|
|
194
|
+
optionsJson: unknown;
|
|
195
|
+
isRequired: boolean;
|
|
196
|
+
sortOrder: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A booking as returned by `getBookingByToken`.
|
|
201
|
+
*
|
|
202
|
+
* Timestamps are ISO-8601 **strings**. The server holds them as `Date` and
|
|
203
|
+
* they cross the wire as JSON, so typing them `Date` here would compile and
|
|
204
|
+
* then fail at runtime on the first `.getTime()`.
|
|
205
|
+
*/
|
|
206
|
+
export interface SchedulingBookingView {
|
|
207
|
+
booking: {
|
|
208
|
+
id: string;
|
|
209
|
+
startAt: string;
|
|
210
|
+
endAt: string;
|
|
211
|
+
status: SchedulingBookingStatus;
|
|
212
|
+
title: string;
|
|
213
|
+
notes: string | null;
|
|
214
|
+
conferenceLink: string | null;
|
|
215
|
+
};
|
|
216
|
+
invitee: { name: string; email: string; timezone: string } | null;
|
|
217
|
+
eventType: {
|
|
218
|
+
name: string;
|
|
219
|
+
slug: string;
|
|
220
|
+
durationMinutes: number;
|
|
221
|
+
locationType: string;
|
|
222
|
+
} | null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** What `rescheduleBooking` echoes back — the booking's new window. */
|
|
226
|
+
export interface SchedulingRescheduleResult {
|
|
227
|
+
bookingId: string;
|
|
228
|
+
startAt: string;
|
|
229
|
+
endAt: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface SchedulingSlot {
|
|
233
|
+
startAt: string;
|
|
234
|
+
endAt: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface SchedulingInvitee {
|
|
238
|
+
email: string;
|
|
239
|
+
name: string;
|
|
240
|
+
phone?: string | null;
|
|
241
|
+
timezone: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface SchedulingAnswer {
|
|
245
|
+
questionId: string;
|
|
246
|
+
valueText: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Booking lifecycle states, mirroring the `booking_status` pg enum.
|
|
251
|
+
*
|
|
252
|
+
* Note the spelling: the platform stores **`canceled`** (one L). This type
|
|
253
|
+
* previously read `"pending" | "confirmed" | "cancelled" | string`, which was
|
|
254
|
+
* wrong twice over — the two-L spelling matches nothing the server ever
|
|
255
|
+
* sends, and the trailing `| string` widened the union back to `string` so
|
|
256
|
+
* neither the typo nor the missing `no_show` could ever surface. Keep this
|
|
257
|
+
* closed; a new state should land here as a deliberate edit.
|
|
258
|
+
*/
|
|
259
|
+
export type SchedulingBookingStatus =
|
|
260
|
+
| "confirmed"
|
|
261
|
+
| "pending"
|
|
262
|
+
| "canceled"
|
|
263
|
+
| "no_show";
|
|
264
|
+
|
|
265
|
+
export interface SchedulingBookingResult {
|
|
266
|
+
bookingId: string;
|
|
267
|
+
status: SchedulingBookingStatus;
|
|
268
|
+
manageToken: string;
|
|
269
|
+
rescheduleToken: string;
|
|
270
|
+
cancelToken: string;
|
|
271
|
+
payment: {
|
|
272
|
+
clientSecret: string;
|
|
273
|
+
amountCents: number;
|
|
274
|
+
currency: string;
|
|
275
|
+
} | null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface SchedulingResource {
|
|
279
|
+
listEventTypes(): Promise<SchedulingEventType[]>;
|
|
280
|
+
getEventType(slug: string): Promise<{
|
|
281
|
+
eventType: SchedulingEventType;
|
|
282
|
+
questions: SchedulingQuestion[];
|
|
283
|
+
}>;
|
|
284
|
+
getAvailableSlots(
|
|
285
|
+
slug: string,
|
|
286
|
+
params: { from: Date; to: Date },
|
|
287
|
+
): Promise<SchedulingSlot[]>;
|
|
288
|
+
confirmBooking(input: {
|
|
289
|
+
eventTypeSlug: string;
|
|
290
|
+
startAt: Date;
|
|
291
|
+
invitee: SchedulingInvitee;
|
|
292
|
+
answers?: SchedulingAnswer[];
|
|
293
|
+
notes?: string | null;
|
|
294
|
+
schedulingLinkId?: string;
|
|
295
|
+
holderKey?: string | null;
|
|
296
|
+
}): Promise<SchedulingBookingResult>;
|
|
297
|
+
getBookingByToken(
|
|
298
|
+
token: string,
|
|
299
|
+
type?: "manage" | "cancel" | "reschedule",
|
|
300
|
+
): Promise<SchedulingBookingView>;
|
|
301
|
+
rescheduleBooking(input: {
|
|
302
|
+
token: string;
|
|
303
|
+
newStartAt: Date;
|
|
304
|
+
reason?: string | null;
|
|
305
|
+
}): Promise<SchedulingRescheduleResult>;
|
|
306
|
+
cancelBooking(input: {
|
|
307
|
+
token: string;
|
|
308
|
+
reason?: string | null;
|
|
309
|
+
}): Promise<{ ok: true }>;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function createSchedulingResource(state: InternalState): SchedulingResource {
|
|
313
|
+
const sitePath = `sites/${encodeURIComponent(state.siteId)}/scheduling`;
|
|
314
|
+
async function postJson<T>(
|
|
315
|
+
path: string,
|
|
316
|
+
body: unknown,
|
|
317
|
+
tags: string[],
|
|
318
|
+
): Promise<T> {
|
|
319
|
+
const url = `${state.baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
320
|
+
const init: RequestInit & { next?: unknown } = {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: { ...buildHeaders(state), "Content-Type": "application/json" },
|
|
323
|
+
body: JSON.stringify(body),
|
|
324
|
+
next: { tags, revalidate: 0 },
|
|
325
|
+
};
|
|
326
|
+
const res = await state.fetch(url, init);
|
|
327
|
+
if (!res.ok) {
|
|
328
|
+
const text = await res.text().catch(() => "");
|
|
329
|
+
throw new Error(
|
|
330
|
+
`BIAB SDK ${res.status} ${res.statusText} on ${path}: ${text.slice(0, 200)}`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return (await res.json()) as T;
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
async listEventTypes() {
|
|
337
|
+
const tags = ["biab:scheduling", `biab:site:${state.siteId}`];
|
|
338
|
+
const result = await jsonGet<{ items: SchedulingEventType[] }>(
|
|
339
|
+
state,
|
|
340
|
+
`${sitePath}/event-types`,
|
|
341
|
+
tags,
|
|
342
|
+
);
|
|
343
|
+
return result.items ?? [];
|
|
344
|
+
},
|
|
345
|
+
async getEventType(slug) {
|
|
346
|
+
const tags = [
|
|
347
|
+
"biab:scheduling",
|
|
348
|
+
`biab:scheduling:event:${slug}`,
|
|
349
|
+
`biab:site:${state.siteId}`,
|
|
350
|
+
];
|
|
351
|
+
return await jsonGet(
|
|
352
|
+
state,
|
|
353
|
+
`${sitePath}/event-types/${encodeURIComponent(slug)}`,
|
|
354
|
+
tags,
|
|
355
|
+
);
|
|
356
|
+
},
|
|
357
|
+
async getAvailableSlots(slug, params) {
|
|
358
|
+
const query = new URLSearchParams({
|
|
359
|
+
from: params.from.toISOString(),
|
|
360
|
+
to: params.to.toISOString(),
|
|
361
|
+
});
|
|
362
|
+
const path = `${sitePath}/event-types/${encodeURIComponent(slug)}/slots?${query.toString()}`;
|
|
363
|
+
// Slots are dynamic — no caching, no tags. revalidate: 0 keeps
|
|
364
|
+
// Next from applying the default backstop.
|
|
365
|
+
const url = `${state.baseUrl.replace(/\/$/, "")}/${path}`;
|
|
366
|
+
const init: RequestInit & { next?: unknown } = {
|
|
367
|
+
method: "GET",
|
|
368
|
+
headers: buildHeaders(state),
|
|
369
|
+
next: { revalidate: 0 },
|
|
370
|
+
cache: "no-store",
|
|
371
|
+
};
|
|
372
|
+
const res = await state.fetch(url, init);
|
|
373
|
+
if (!res.ok) {
|
|
374
|
+
const text = await res.text().catch(() => "");
|
|
375
|
+
throw new Error(
|
|
376
|
+
`BIAB SDK ${res.status} ${res.statusText} on ${path}: ${text.slice(0, 200)}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const data = (await res.json()) as { slots: SchedulingSlot[] };
|
|
380
|
+
return data.slots ?? [];
|
|
381
|
+
},
|
|
382
|
+
async confirmBooking(input) {
|
|
383
|
+
return await postJson<SchedulingBookingResult>(
|
|
384
|
+
`${sitePath}/bookings`,
|
|
385
|
+
{
|
|
386
|
+
eventTypeSlug: input.eventTypeSlug,
|
|
387
|
+
startAt: input.startAt.toISOString(),
|
|
388
|
+
invitee: input.invitee,
|
|
389
|
+
answers: input.answers,
|
|
390
|
+
notes: input.notes ?? null,
|
|
391
|
+
schedulingLinkId: input.schedulingLinkId,
|
|
392
|
+
holderKey: input.holderKey ?? null,
|
|
393
|
+
},
|
|
394
|
+
[],
|
|
395
|
+
);
|
|
396
|
+
},
|
|
397
|
+
async getBookingByToken(token, type = "manage") {
|
|
398
|
+
const path = `${sitePath}/bookings/${encodeURIComponent(token)}?type=${type}`;
|
|
399
|
+
const url = `${state.baseUrl.replace(/\/$/, "")}/${path}`;
|
|
400
|
+
const res = await state.fetch(url, {
|
|
401
|
+
method: "GET",
|
|
402
|
+
headers: buildHeaders(state),
|
|
403
|
+
cache: "no-store",
|
|
404
|
+
});
|
|
405
|
+
if (!res.ok) {
|
|
406
|
+
const text = await res.text().catch(() => "");
|
|
407
|
+
throw new Error(
|
|
408
|
+
`BIAB SDK ${res.status} ${res.statusText}: ${text.slice(0, 200)}`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return (await res.json()) as SchedulingBookingView;
|
|
412
|
+
},
|
|
413
|
+
async rescheduleBooking(input) {
|
|
414
|
+
return await postJson<SchedulingRescheduleResult>(
|
|
415
|
+
`${sitePath}/bookings/${encodeURIComponent(input.token)}`,
|
|
416
|
+
{
|
|
417
|
+
action: "reschedule",
|
|
418
|
+
newStartAt: input.newStartAt.toISOString(),
|
|
419
|
+
reason: input.reason ?? null,
|
|
420
|
+
},
|
|
421
|
+
[],
|
|
422
|
+
);
|
|
423
|
+
},
|
|
424
|
+
async cancelBooking(input) {
|
|
425
|
+
return await postJson<{ ok: true }>(
|
|
426
|
+
`${sitePath}/bookings/${encodeURIComponent(input.token)}`,
|
|
427
|
+
{
|
|
428
|
+
action: "cancel",
|
|
429
|
+
reason: input.reason ?? null,
|
|
430
|
+
},
|
|
431
|
+
[],
|
|
432
|
+
);
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Forms resource — schema export + runtime validator
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Public-facing description of one form field. Mirrors BIAB's
|
|
443
|
+
* `FormField` type so the consumer can type their submission payload
|
|
444
|
+
* against the schema fetched at runtime.
|
|
445
|
+
*/
|
|
446
|
+
export interface FormFieldDef {
|
|
447
|
+
id: string;
|
|
448
|
+
/**
|
|
449
|
+
* Stable, human-readable output key for this field's value (camelCase or
|
|
450
|
+
* snake_case, per the form's configuration). Prefer this when building the
|
|
451
|
+
* submission payload. The server also accepts the UUID `id` for back-compat,
|
|
452
|
+
* so older integrations keep working.
|
|
453
|
+
*/
|
|
454
|
+
key?: string;
|
|
455
|
+
label: string;
|
|
456
|
+
type: string;
|
|
457
|
+
required: boolean;
|
|
458
|
+
/**
|
|
459
|
+
* Consent semantic for `type === "boolean"`: the value must be `true` to
|
|
460
|
+
* submit (e.g. "I agree"). Independent of `display`. When false/unset, an
|
|
461
|
+
* optional/required boolean accepts true OR false.
|
|
462
|
+
*/
|
|
463
|
+
mustBeTrue?: boolean;
|
|
464
|
+
/** Boolean UI variant: "toggle" (default switch) or "checkbox". */
|
|
465
|
+
display?: "toggle" | "checkbox";
|
|
466
|
+
/**
|
|
467
|
+
* select / radio / checkbox: an "Other…" free-text value outside `options`
|
|
468
|
+
* is allowed. When true, the value may be any string (single) or include a
|
|
469
|
+
* non-option string (checkbox array); the options whitelist isn't enforced.
|
|
470
|
+
*/
|
|
471
|
+
allowCustom?: boolean;
|
|
472
|
+
placeholder?: string;
|
|
473
|
+
helpText?: string;
|
|
474
|
+
options?: string[];
|
|
475
|
+
regexPattern?: string;
|
|
476
|
+
validation?: { min?: number; max?: number };
|
|
477
|
+
/**
|
|
478
|
+
* Set on file-type fields (`type === "file_upload"`). The SDK
|
|
479
|
+
* uploader enforces these client-side as a UX nicety; the
|
|
480
|
+
* server is the source of truth and re-enforces them when
|
|
481
|
+
* minting the upload URL.
|
|
482
|
+
*/
|
|
483
|
+
fileConfig?: {
|
|
484
|
+
allowedTypes?: string[];
|
|
485
|
+
maxFiles?: number;
|
|
486
|
+
maxSize?: number;
|
|
487
|
+
};
|
|
488
|
+
/** Phone-field config — `international` switches the digit-count rule. */
|
|
489
|
+
phoneConfig?: {
|
|
490
|
+
international?: boolean;
|
|
491
|
+
};
|
|
492
|
+
/**
|
|
493
|
+
* choice_cards config: a set of selectable cards. `single` = radio semantics
|
|
494
|
+
* (value is the chosen option `label`); `multi` = checkbox semantics (value is
|
|
495
|
+
* a `string[]` of chosen labels). Options may carry richer card content
|
|
496
|
+
* (image/price/description/etc.) for showcasing a product or service — that
|
|
497
|
+
* content is display + metadata only; the submitted value is always the
|
|
498
|
+
* `label`, so validation is unaffected by the extra fields.
|
|
499
|
+
*/
|
|
500
|
+
choiceConfig?: {
|
|
501
|
+
mode?: "single" | "multi";
|
|
502
|
+
options?: Array<{
|
|
503
|
+
label: string;
|
|
504
|
+
/** Lucide icon name (kebab-case, e.g. "phone"). */
|
|
505
|
+
icon?: string;
|
|
506
|
+
/** Image to show on the card (URL). */
|
|
507
|
+
imageUrl?: string;
|
|
508
|
+
/** Display price string (e.g. "$49/mo"). */
|
|
509
|
+
price?: string;
|
|
510
|
+
/** Short supporting description shown under the label. */
|
|
511
|
+
description?: string;
|
|
512
|
+
/** Your catalog id for this option (product/service) — metadata only. */
|
|
513
|
+
productId?: string;
|
|
514
|
+
/** Optional link for the card (e.g. a product detail page). */
|
|
515
|
+
href?: string;
|
|
516
|
+
}>;
|
|
517
|
+
};
|
|
518
|
+
/**
|
|
519
|
+
* Display content for static elements. For `richtext` this is
|
|
520
|
+
* server-sanitized HTML; for `body_text` it is plain text. Carries no
|
|
521
|
+
* submitted value.
|
|
522
|
+
*/
|
|
523
|
+
content?: string;
|
|
524
|
+
/**
|
|
525
|
+
* `bullet_list` display element. `variant: "bullets"` → optional `title` +
|
|
526
|
+
* plain bullet `items[].text`; `variant: "details"` → one row per point with
|
|
527
|
+
* an optional Lucide `icon`, a `text` heading, and a `description`. Display
|
|
528
|
+
* only — carries no submitted value.
|
|
529
|
+
*/
|
|
530
|
+
listConfig?: {
|
|
531
|
+
variant?: "bullets" | "details";
|
|
532
|
+
title?: string;
|
|
533
|
+
items?: Array<{ text: string; description?: string; icon?: string }>;
|
|
534
|
+
};
|
|
535
|
+
/**
|
|
536
|
+
* `table` display element. `columns` (header / `type` "text"|"check" / align)
|
|
537
|
+
* + `rows[].cells` aligned to columns by index; a "check" column renders ✓/–
|
|
538
|
+
* from a truthy/falsy cell. `highlightColumn` emphasizes one column. Display
|
|
539
|
+
* only — carries no submitted value.
|
|
540
|
+
*/
|
|
541
|
+
tableConfig?: {
|
|
542
|
+
columns?: Array<{
|
|
543
|
+
header: string;
|
|
544
|
+
type?: "text" | "check";
|
|
545
|
+
align?: "left" | "center" | "right";
|
|
546
|
+
}>;
|
|
547
|
+
rows?: Array<{ cells: string[] }>;
|
|
548
|
+
highlightColumn?: number;
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* `multi_populator` repeater: the submitted value is an ARRAY of instance
|
|
552
|
+
* records, each keyed by the child field ids/keys (same shape as a scoped
|
|
553
|
+
* submission). `maxInstances` caps the count. The template fields live on
|
|
554
|
+
* `children` in the rendered schema tree.
|
|
555
|
+
*/
|
|
556
|
+
populatorConfig?: {
|
|
557
|
+
maxInstances?: number;
|
|
558
|
+
minInstances?: number;
|
|
559
|
+
addButtonLabel?: string;
|
|
560
|
+
itemLabel?: string;
|
|
561
|
+
};
|
|
562
|
+
/**
|
|
563
|
+
* `measurement` field: a number + unit. The submitted value is
|
|
564
|
+
* `{ value, unit, base, baseUnit }` — `base` is `value` in the category's base
|
|
565
|
+
* unit (server-computed, for downstream sorting). `outputUnit` is the org's
|
|
566
|
+
* preferred unit (convert `base` → `outputUnit` to resolve internally).
|
|
567
|
+
*/
|
|
568
|
+
unitConfig?: {
|
|
569
|
+
category?: string;
|
|
570
|
+
units?: string[];
|
|
571
|
+
mode?: "select" | "fixed";
|
|
572
|
+
defaultUnit?: string;
|
|
573
|
+
outputUnit?: string;
|
|
574
|
+
allowDecimal?: boolean;
|
|
575
|
+
};
|
|
576
|
+
/** `number` field: when false, only whole numbers are accepted. */
|
|
577
|
+
allowDecimal?: boolean;
|
|
578
|
+
/** `number` field: when false, negatives are rejected (enforce >= 0). */
|
|
579
|
+
allowNegative?: boolean;
|
|
580
|
+
/** `currency` field: ISO code / symbol + whether negatives are allowed. */
|
|
581
|
+
currencyConfig?: {
|
|
582
|
+
currency?: string;
|
|
583
|
+
symbol?: string;
|
|
584
|
+
allowNegative?: boolean;
|
|
585
|
+
allowDecimal?: boolean;
|
|
586
|
+
};
|
|
587
|
+
/**
|
|
588
|
+
* `calculation` (Math) element: a value computed from other fields via an
|
|
589
|
+
* inline `formula` or a referenced business `functionId` (pick `outputKey`).
|
|
590
|
+
* `inputMap` binds variable / function-input names to source field ids.
|
|
591
|
+
* Non-sensitive calcs compute live client-side (using `schema.businessFunctions`
|
|
592
|
+
* for function refs); `sensitive` ones compute server-side on submit and are
|
|
593
|
+
* published WITHOUT their formula/def — the field shows a placeholder until
|
|
594
|
+
* the submitted value comes back.
|
|
595
|
+
*/
|
|
596
|
+
calculationConfig?: {
|
|
597
|
+
mode?: "formula" | "function";
|
|
598
|
+
formula?: string;
|
|
599
|
+
functionId?: string;
|
|
600
|
+
outputKey?: string;
|
|
601
|
+
inputMap?: Array<{ variable: string; fieldId: string }>;
|
|
602
|
+
display?: {
|
|
603
|
+
show?: boolean;
|
|
604
|
+
prefix?: string;
|
|
605
|
+
suffix?: string;
|
|
606
|
+
decimals?: number;
|
|
607
|
+
};
|
|
608
|
+
sensitive?: boolean;
|
|
609
|
+
};
|
|
610
|
+
/**
|
|
611
|
+
* `credit_card` element — collect a card via Stripe Elements (`<BiabForm/>`
|
|
612
|
+
* only). `card_on_file` saves the card (SetupIntent); `deposit` charges
|
|
613
|
+
* `amountCents` now (PaymentIntent). The card is created on the org's Stripe
|
|
614
|
+
* connected account via `forms.paymentIntent(slug, fieldId)`; the submission
|
|
615
|
+
* stores only the opaque intent id + brand/last4 (PCI-safe). Added in 0.9.9.
|
|
616
|
+
*/
|
|
617
|
+
paymentConfig?: {
|
|
618
|
+
mode?: "card_on_file" | "deposit";
|
|
619
|
+
amountCents?: number;
|
|
620
|
+
currency?: string;
|
|
621
|
+
buttonLabel?: string;
|
|
622
|
+
description?: string;
|
|
623
|
+
};
|
|
624
|
+
/**
|
|
625
|
+
* `schedule` element (availability v1). The visitor states when they're free;
|
|
626
|
+
* the submitted value is `{ asap?, days?: string[], times?: string[], notes? }`.
|
|
627
|
+
* `<BiabForm/>` renders day + time-window chips, an ASAP toggle, and notes.
|
|
628
|
+
* Added in 0.9.9.
|
|
629
|
+
*/
|
|
630
|
+
scheduleConfig?: {
|
|
631
|
+
mode?: "availability";
|
|
632
|
+
allowAsap?: boolean;
|
|
633
|
+
days?: string[];
|
|
634
|
+
timeWindows?: string[];
|
|
635
|
+
allowNotes?: boolean;
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Value shape stored in `data` for a file field after the upload
|
|
641
|
+
* completes. The hiring mirror at `src/server/forms/hiring-mirror.ts`
|
|
642
|
+
* reads these exact fields to populate `job_application_files` — the
|
|
643
|
+
* SDK keeps the contract honest.
|
|
644
|
+
*/
|
|
645
|
+
export interface FormFileValue {
|
|
646
|
+
storageId: string;
|
|
647
|
+
url: string;
|
|
648
|
+
name: string;
|
|
649
|
+
mimeType: string;
|
|
650
|
+
size: number;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** How the org configured the form to render — apply these for a 1:1 match. */
|
|
654
|
+
export interface FormRenderSettings {
|
|
655
|
+
animation?: "none" | "fade" | "slide" | "scale" | "scroll";
|
|
656
|
+
isConsecutive?: boolean;
|
|
657
|
+
showProgress?: boolean;
|
|
658
|
+
progressStyle?: "bar" | "steps";
|
|
659
|
+
/**
|
|
660
|
+
* Warn before the visitor abandons a dirty, unsubmitted form. `<BiabForm/>`
|
|
661
|
+
* attaches a `beforeunload` guard when `enabled` (the browser shows a generic
|
|
662
|
+
* prompt); `message` is provided so you can show it in your own modal too.
|
|
663
|
+
*/
|
|
664
|
+
exitConfirm?: { enabled?: boolean; message?: string };
|
|
665
|
+
/**
|
|
666
|
+
* Present (true) only when the org's subscription (or platform-admin access)
|
|
667
|
+
* grants link-safety checking AND the form has url fields. When absent, the
|
|
668
|
+
* org hasn't paid for it — do no extra URL verification. The reputation scan
|
|
669
|
+
* runs server-side on submit (it needs the platform's API key); this flag just
|
|
670
|
+
* tells the form the feature is active so you can surface it.
|
|
671
|
+
*/
|
|
672
|
+
urlSafety?: boolean;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Action types a form can run on submit (see {@link FormActionPreview}). */
|
|
676
|
+
export type FormActionType =
|
|
677
|
+
| "add_follower"
|
|
678
|
+
| "unsubscribe"
|
|
679
|
+
| "send_webhook"
|
|
680
|
+
| "autoresponder_email"
|
|
681
|
+
| "grant_coupon";
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Sanitized preview of an on-submit action — `type` + the optional condition
|
|
685
|
+
* that gates it. The action's wiring (URLs, secrets, email bodies, field
|
|
686
|
+
* mapping) stays server-side; execution happens automatically on submit. Use
|
|
687
|
+
* this to preview to the user what submitting will do (e.g. "you'll be added
|
|
688
|
+
* to the list"). Added in 0.9.7.
|
|
689
|
+
*/
|
|
690
|
+
export interface FormActionPreview {
|
|
691
|
+
type: FormActionType;
|
|
692
|
+
condition?: {
|
|
693
|
+
fieldId: string;
|
|
694
|
+
operator: string;
|
|
695
|
+
value?: string;
|
|
696
|
+
} | null;
|
|
697
|
+
/**
|
|
698
|
+
* `grant_coupon` only: the display-safe coupon notice to show before submit
|
|
699
|
+
* (e.g. "{code} will be applied to this inquiry"). `<BiabForm/>` renders it
|
|
700
|
+
* above the submit button. The coupon id / field mapping never leave the
|
|
701
|
+
* server. Added in 0.9.9.
|
|
702
|
+
*/
|
|
703
|
+
coupon?: { code?: string; notice?: string; finePrint?: string };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export interface FormSchema {
|
|
707
|
+
id: string;
|
|
708
|
+
slug: string;
|
|
709
|
+
title?: string;
|
|
710
|
+
description?: string | null;
|
|
711
|
+
fields: FormFieldDef[];
|
|
712
|
+
/** Render settings (animation, multi-step, progress). Added in 0.9.7. */
|
|
713
|
+
settings?: FormRenderSettings;
|
|
714
|
+
/** Org icon URL when the form opts into `showOrgIcon`. Added in 0.9.7. */
|
|
715
|
+
orgIcon?: string;
|
|
716
|
+
/** On-submit actions this form will run (sanitized). Added in 0.9.7. */
|
|
717
|
+
actions?: FormActionPreview[];
|
|
718
|
+
/**
|
|
719
|
+
* NON-sensitive business functions referenced by this form's `calculation`
|
|
720
|
+
* fields, so the SDK can compute them live in the browser. Sensitive
|
|
721
|
+
* functions are NEVER included — they run server-side on submit. Added in
|
|
722
|
+
* 0.9.9.
|
|
723
|
+
*/
|
|
724
|
+
businessFunctions?: Array<{
|
|
725
|
+
id: string;
|
|
726
|
+
slug: string;
|
|
727
|
+
inputs: Array<{
|
|
728
|
+
key: string;
|
|
729
|
+
label?: string;
|
|
730
|
+
type: string;
|
|
731
|
+
required?: boolean;
|
|
732
|
+
default?: unknown;
|
|
733
|
+
}>;
|
|
734
|
+
outputs: Array<{
|
|
735
|
+
key: string;
|
|
736
|
+
label?: string;
|
|
737
|
+
type: string;
|
|
738
|
+
formula: string;
|
|
739
|
+
fallback?: unknown;
|
|
740
|
+
}>;
|
|
741
|
+
}>;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export type FormSubmissionData = Record<string, unknown>;
|
|
745
|
+
|
|
746
|
+
export interface FormValidationIssue {
|
|
747
|
+
fieldId: string;
|
|
748
|
+
label: string;
|
|
749
|
+
reason:
|
|
750
|
+
| "required"
|
|
751
|
+
| "type"
|
|
752
|
+
| "min"
|
|
753
|
+
| "max"
|
|
754
|
+
| "regex"
|
|
755
|
+
| "options"
|
|
756
|
+
| "format"
|
|
757
|
+
| "unknown_field"
|
|
758
|
+
| "invalid_payload"
|
|
759
|
+
| "too_many_fields"
|
|
760
|
+
// Forward-compat: tolerate reasons added server-side without a type bump.
|
|
761
|
+
| (string & {});
|
|
762
|
+
message: string;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export interface FormValidationResult {
|
|
766
|
+
ok: boolean;
|
|
767
|
+
issues: FormValidationIssue[];
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Runtime validate a submission against the fetched form schema.
|
|
772
|
+
* Returns `ok: true` only when every required field is present and
|
|
773
|
+
* every supplied value passes its type / min / max / regex /
|
|
774
|
+
* options check. Designed to mirror what BIAB's server enforces so
|
|
775
|
+
* the consumer can show inline errors before round-tripping.
|
|
776
|
+
*
|
|
777
|
+
* For build-time type safety, the future `biab-dev codegen forms`
|
|
778
|
+
* CLI emits a `.d.ts` per form so TypeScript can also enforce
|
|
779
|
+
* shape at compile time. Until that ships, this runtime check is
|
|
780
|
+
* the safety net.
|
|
781
|
+
*/
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Narrow check for the shape `biab.forms.uploadFile` returns —
|
|
785
|
+
* mirrors what the BIAB hiring mirror reads (storageId, url, name,
|
|
786
|
+
* mimeType, size). Used by both `validateFormSubmission` and any
|
|
787
|
+
* caller that wants to assert "this value is a successful upload."
|
|
788
|
+
*/
|
|
789
|
+
export function isFormFileValue(value: unknown): value is FormFileValue {
|
|
790
|
+
if (!value || typeof value !== "object") return false;
|
|
791
|
+
const v = value as Partial<FormFileValue>;
|
|
792
|
+
return (
|
|
793
|
+
typeof v.storageId === "string" &&
|
|
794
|
+
typeof v.url === "string" &&
|
|
795
|
+
typeof v.name === "string" &&
|
|
796
|
+
typeof v.mimeType === "string" &&
|
|
797
|
+
typeof v.size === "number"
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export function validateFormSubmission(
|
|
802
|
+
schema: FormSchema,
|
|
803
|
+
data: FormSubmissionData,
|
|
804
|
+
): FormValidationResult {
|
|
805
|
+
const issues: FormValidationIssue[] = [];
|
|
806
|
+
// A submission may key values by the field's output `key` (preferred) or its
|
|
807
|
+
// legacy UUID `id`. Accept both everywhere.
|
|
808
|
+
const knownFieldIds = new Set<string>();
|
|
809
|
+
for (const f of schema.fields) {
|
|
810
|
+
knownFieldIds.add(f.id);
|
|
811
|
+
if (f.key) knownFieldIds.add(f.key);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
for (const field of schema.fields) {
|
|
815
|
+
// `calculation` fields are computed server-side on submit — never user-validated.
|
|
816
|
+
if (field.type === "calculation") continue;
|
|
817
|
+
const value =
|
|
818
|
+
field.key != null && field.key in data ? data[field.key] : data[field.id];
|
|
819
|
+
const scheduleEmpty =
|
|
820
|
+
field.type === "schedule" &&
|
|
821
|
+
(() => {
|
|
822
|
+
const s = (value ?? {}) as {
|
|
823
|
+
slotStartAt?: unknown;
|
|
824
|
+
asap?: boolean;
|
|
825
|
+
days?: unknown[];
|
|
826
|
+
times?: unknown[];
|
|
827
|
+
};
|
|
828
|
+
return !(
|
|
829
|
+
(typeof s.slotStartAt === "string" && s.slotStartAt.length > 0) ||
|
|
830
|
+
s.asap === true ||
|
|
831
|
+
(Array.isArray(s.days) && s.days.length > 0) ||
|
|
832
|
+
(Array.isArray(s.times) && s.times.length > 0)
|
|
833
|
+
);
|
|
834
|
+
})();
|
|
835
|
+
const urlEmpty =
|
|
836
|
+
field.type === "url" &&
|
|
837
|
+
!(typeof (value as { url?: unknown })?.url === "string"
|
|
838
|
+
? (value as { url: string }).url
|
|
839
|
+
: "");
|
|
840
|
+
const isEmpty =
|
|
841
|
+
value == null ||
|
|
842
|
+
value === "" ||
|
|
843
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
844
|
+
scheduleEmpty ||
|
|
845
|
+
urlEmpty;
|
|
846
|
+
// Consent boolean: must be exactly `true` (independent of `required`).
|
|
847
|
+
// `false` is otherwise a valid answer for an optional/required toggle.
|
|
848
|
+
const consentUnmet = field.mustBeTrue === true && value !== true;
|
|
849
|
+
if ((field.required && isEmpty) || consentUnmet) {
|
|
850
|
+
issues.push({
|
|
851
|
+
fieldId: field.id,
|
|
852
|
+
label: field.label,
|
|
853
|
+
reason: "required",
|
|
854
|
+
message: consentUnmet
|
|
855
|
+
? `${field.label} must be checked.`
|
|
856
|
+
: `${field.label} is required.`,
|
|
857
|
+
});
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if (isEmpty) continue;
|
|
861
|
+
switch (field.type) {
|
|
862
|
+
case "email": {
|
|
863
|
+
if (
|
|
864
|
+
typeof value !== "string" ||
|
|
865
|
+
!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)
|
|
866
|
+
) {
|
|
867
|
+
issues.push({
|
|
868
|
+
fieldId: field.id,
|
|
869
|
+
label: field.label,
|
|
870
|
+
reason: "type",
|
|
871
|
+
message: `${field.label} must be a valid email.`,
|
|
872
|
+
});
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
if (
|
|
876
|
+
(field as { emailConfig?: { businessOnly?: boolean } }).emailConfig
|
|
877
|
+
?.businessOnly &&
|
|
878
|
+
isConsumerEmailDomain(getEmailDomain(value) ?? "")
|
|
879
|
+
) {
|
|
880
|
+
issues.push({
|
|
881
|
+
fieldId: field.id,
|
|
882
|
+
label: field.label,
|
|
883
|
+
reason: "type",
|
|
884
|
+
message: `${field.label} must be a business email address.`,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
case "url": {
|
|
890
|
+
// Value object: { url, verified }. Validate the `url` member.
|
|
891
|
+
const u = (value as { url?: unknown })?.url;
|
|
892
|
+
if (
|
|
893
|
+
typeof u !== "string" ||
|
|
894
|
+
!/^(https?:\/\/)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:[/?#]\S*)?$/i.test(
|
|
895
|
+
u.trim(),
|
|
896
|
+
)
|
|
897
|
+
) {
|
|
898
|
+
issues.push({
|
|
899
|
+
fieldId: field.id,
|
|
900
|
+
label: field.label,
|
|
901
|
+
reason: "type",
|
|
902
|
+
message: `${field.label} must be a valid URL.`,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
case "phone": {
|
|
908
|
+
// Digit-count pre-check mirroring the server: international fields
|
|
909
|
+
// allow E.164 (7-15), otherwise a 10-digit US number (optionally
|
|
910
|
+
// with a leading "1"). The server is the authoritative check.
|
|
911
|
+
const digits =
|
|
912
|
+
typeof value === "string" ? value.replace(/\D/g, "") : "";
|
|
913
|
+
const intl = field.phoneConfig?.international === true;
|
|
914
|
+
const national =
|
|
915
|
+
digits.length === 11 && digits.startsWith("1")
|
|
916
|
+
? digits.slice(1)
|
|
917
|
+
: digits;
|
|
918
|
+
const ok = intl
|
|
919
|
+
? digits.length >= 7 && digits.length <= 15
|
|
920
|
+
: national.length === 10;
|
|
921
|
+
if (!ok) {
|
|
922
|
+
issues.push({
|
|
923
|
+
fieldId: field.id,
|
|
924
|
+
label: field.label,
|
|
925
|
+
reason: "type",
|
|
926
|
+
message: `${field.label} must be a valid phone number.`,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
case "number":
|
|
932
|
+
case "slider":
|
|
933
|
+
if (typeof value !== "number") {
|
|
934
|
+
issues.push({
|
|
935
|
+
fieldId: field.id,
|
|
936
|
+
label: field.label,
|
|
937
|
+
reason: "type",
|
|
938
|
+
message: `${field.label} must be a number.`,
|
|
939
|
+
});
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
if (
|
|
943
|
+
field.type === "number" &&
|
|
944
|
+
(field as { allowNegative?: boolean }).allowNegative === false &&
|
|
945
|
+
value < 0
|
|
946
|
+
) {
|
|
947
|
+
issues.push({
|
|
948
|
+
fieldId: field.id,
|
|
949
|
+
label: field.label,
|
|
950
|
+
reason: "min",
|
|
951
|
+
message: `${field.label} must be 0 or greater.`,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
if (field.validation?.min != null && value < field.validation.min) {
|
|
955
|
+
issues.push({
|
|
956
|
+
fieldId: field.id,
|
|
957
|
+
label: field.label,
|
|
958
|
+
reason: "min",
|
|
959
|
+
message: `${field.label} must be at least ${field.validation.min}.`,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
if (field.validation?.max != null && value > field.validation.max) {
|
|
963
|
+
issues.push({
|
|
964
|
+
fieldId: field.id,
|
|
965
|
+
label: field.label,
|
|
966
|
+
reason: "max",
|
|
967
|
+
message: `${field.label} must be at most ${field.validation.max}.`,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
break;
|
|
971
|
+
case "select":
|
|
972
|
+
case "radio":
|
|
973
|
+
if (
|
|
974
|
+
field.options &&
|
|
975
|
+
!field.allowCustom &&
|
|
976
|
+
!field.options.includes(String(value))
|
|
977
|
+
) {
|
|
978
|
+
issues.push({
|
|
979
|
+
fieldId: field.id,
|
|
980
|
+
label: field.label,
|
|
981
|
+
reason: "options",
|
|
982
|
+
message: `${field.label} must be one of: ${field.options.join(", ")}.`,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
break;
|
|
986
|
+
case "checkbox":
|
|
987
|
+
if (typeof value !== "boolean" && !Array.isArray(value)) {
|
|
988
|
+
issues.push({
|
|
989
|
+
fieldId: field.id,
|
|
990
|
+
label: field.label,
|
|
991
|
+
reason: "type",
|
|
992
|
+
message: `${field.label} must be a boolean or list.`,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
break;
|
|
996
|
+
case "choice_cards": {
|
|
997
|
+
const ccLabels = (field.choiceConfig?.options ?? []).map(
|
|
998
|
+
(o) => o.label,
|
|
999
|
+
);
|
|
1000
|
+
const ccMulti = field.choiceConfig?.mode === "multi";
|
|
1001
|
+
const ccVals = ccMulti
|
|
1002
|
+
? Array.isArray(value)
|
|
1003
|
+
? (value as unknown[])
|
|
1004
|
+
: null
|
|
1005
|
+
: typeof value === "string"
|
|
1006
|
+
? [value]
|
|
1007
|
+
: null;
|
|
1008
|
+
if (ccVals === null) {
|
|
1009
|
+
issues.push({
|
|
1010
|
+
fieldId: field.id,
|
|
1011
|
+
label: field.label,
|
|
1012
|
+
reason: "type",
|
|
1013
|
+
message: ccMulti
|
|
1014
|
+
? `${field.label} must be a list.`
|
|
1015
|
+
: `${field.label} must be a string.`,
|
|
1016
|
+
});
|
|
1017
|
+
break;
|
|
1018
|
+
}
|
|
1019
|
+
if (ccLabels.length) {
|
|
1020
|
+
for (const v of ccVals) {
|
|
1021
|
+
if (typeof v !== "string" || !ccLabels.includes(v)) {
|
|
1022
|
+
issues.push({
|
|
1023
|
+
fieldId: field.id,
|
|
1024
|
+
label: field.label,
|
|
1025
|
+
reason: "options",
|
|
1026
|
+
message: `${field.label} must be one of: ${ccLabels.join(", ")}.`,
|
|
1027
|
+
});
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
case "file_upload":
|
|
1035
|
+
case "file": {
|
|
1036
|
+
const items = Array.isArray(value) ? value : [value];
|
|
1037
|
+
let badShape = false;
|
|
1038
|
+
for (const item of items) {
|
|
1039
|
+
if (!isFormFileValue(item)) {
|
|
1040
|
+
badShape = true;
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
if (
|
|
1044
|
+
field.fileConfig?.maxSize != null &&
|
|
1045
|
+
// `fileConfig.maxSize` is MB (builder slider unit); `item.size`
|
|
1046
|
+
// is bytes — convert before comparing.
|
|
1047
|
+
item.size > field.fileConfig.maxSize * 1024 * 1024
|
|
1048
|
+
) {
|
|
1049
|
+
issues.push({
|
|
1050
|
+
fieldId: field.id,
|
|
1051
|
+
label: field.label,
|
|
1052
|
+
reason: "max",
|
|
1053
|
+
message: `${field.label} exceeds the ${field.fileConfig.maxSize} MB limit.`,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (badShape) {
|
|
1058
|
+
issues.push({
|
|
1059
|
+
fieldId: field.id,
|
|
1060
|
+
label: field.label,
|
|
1061
|
+
reason: "type",
|
|
1062
|
+
message: `${field.label} must be uploaded via biab.forms.uploadFile().`,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
if (
|
|
1066
|
+
field.fileConfig?.maxFiles != null &&
|
|
1067
|
+
Array.isArray(value) &&
|
|
1068
|
+
value.length > field.fileConfig.maxFiles
|
|
1069
|
+
) {
|
|
1070
|
+
issues.push({
|
|
1071
|
+
fieldId: field.id,
|
|
1072
|
+
label: field.label,
|
|
1073
|
+
reason: "max",
|
|
1074
|
+
message: `${field.label} accepts at most ${field.fileConfig.maxFiles} files.`,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
case "schedule": {
|
|
1080
|
+
// { asap?, days?: string[], times?: string[], notes? } — structural
|
|
1081
|
+
// parity with the server (which also enforces length caps). Client
|
|
1082
|
+
// pre-check only; the server gate is authoritative.
|
|
1083
|
+
if (
|
|
1084
|
+
typeof value !== "object" ||
|
|
1085
|
+
value === null ||
|
|
1086
|
+
Array.isArray(value)
|
|
1087
|
+
) {
|
|
1088
|
+
issues.push({
|
|
1089
|
+
fieldId: field.id,
|
|
1090
|
+
label: field.label,
|
|
1091
|
+
reason: "type",
|
|
1092
|
+
message: `${field.label} has an unexpected value.`,
|
|
1093
|
+
});
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
const s = value as {
|
|
1097
|
+
asap?: unknown;
|
|
1098
|
+
days?: unknown;
|
|
1099
|
+
times?: unknown;
|
|
1100
|
+
notes?: unknown;
|
|
1101
|
+
};
|
|
1102
|
+
const badType =
|
|
1103
|
+
(s.asap !== undefined && typeof s.asap !== "boolean") ||
|
|
1104
|
+
(s.notes !== undefined && typeof s.notes !== "string") ||
|
|
1105
|
+
(["days", "times"] as const).some(
|
|
1106
|
+
(k) =>
|
|
1107
|
+
s[k] !== undefined &&
|
|
1108
|
+
(!Array.isArray(s[k]) ||
|
|
1109
|
+
(s[k] as unknown[]).some((x) => typeof x !== "string")),
|
|
1110
|
+
);
|
|
1111
|
+
if (badType) {
|
|
1112
|
+
issues.push({
|
|
1113
|
+
fieldId: field.id,
|
|
1114
|
+
label: field.label,
|
|
1115
|
+
reason: "type",
|
|
1116
|
+
message: `${field.label} has an unexpected value.`,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
break;
|
|
1120
|
+
}
|
|
1121
|
+
default:
|
|
1122
|
+
if (
|
|
1123
|
+
field.regexPattern &&
|
|
1124
|
+
typeof value === "string" &&
|
|
1125
|
+
!new RegExp(field.regexPattern).test(value)
|
|
1126
|
+
) {
|
|
1127
|
+
issues.push({
|
|
1128
|
+
fieldId: field.id,
|
|
1129
|
+
label: field.label,
|
|
1130
|
+
reason: "regex",
|
|
1131
|
+
message: `${field.label} format is invalid.`,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
for (const key of Object.keys(data)) {
|
|
1138
|
+
if (!knownFieldIds.has(key)) {
|
|
1139
|
+
issues.push({
|
|
1140
|
+
fieldId: key,
|
|
1141
|
+
label: key,
|
|
1142
|
+
reason: "unknown_field",
|
|
1143
|
+
message: `Unknown field "${key}" — not in the form schema.`,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return { ok: issues.length === 0, issues };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Why a `forms.submit` call didn't succeed. Lets the consumer branch the UI
|
|
1153
|
+
* without parsing error strings.
|
|
1154
|
+
*
|
|
1155
|
+
* - `client_validation_failed` — the local `validateFormSubmission` caught it
|
|
1156
|
+
* before any network call (see `issues`).
|
|
1157
|
+
* - `validation_failed` — the server rejected the payload against the live form
|
|
1158
|
+
* schema (unknown field, wrong type/format, failed required, oversized, …).
|
|
1159
|
+
* This is also what a value the validator considers unsafe surfaces as. See
|
|
1160
|
+
* `issues`.
|
|
1161
|
+
* - `unauthorized` — missing / invalid / expired package API key (401).
|
|
1162
|
+
* - `forbidden` — the key lacks the scope/permission, or the request origin
|
|
1163
|
+
* isn't on the key's allowed-host list (403).
|
|
1164
|
+
* - `plan_required` — the org's plan doesn't include forms (entitlement gate).
|
|
1165
|
+
* - `payment_required` — the org's BIAB billing is past due.
|
|
1166
|
+
* - `service_suspended` — the org's BIAB billing has lapsed 60+ days.
|
|
1167
|
+
* - `rate_limited` — too many submissions too fast (429).
|
|
1168
|
+
* - `not_found` — the form doesn't exist, is inactive, or isn't accepting
|
|
1169
|
+
* external submissions (404).
|
|
1170
|
+
* - `invalid_request` — the request body was malformed (400).
|
|
1171
|
+
* - `server_error` — BIAB failed to process the submission (5xx).
|
|
1172
|
+
* - `network_error` — the request never reached BIAB (offline, DNS, CORS, …).
|
|
1173
|
+
*/
|
|
1174
|
+
export type FormSubmitFailureReason =
|
|
1175
|
+
| "client_validation_failed"
|
|
1176
|
+
| "validation_failed"
|
|
1177
|
+
| "unauthorized"
|
|
1178
|
+
| "forbidden"
|
|
1179
|
+
| "plan_required"
|
|
1180
|
+
| "payment_required"
|
|
1181
|
+
| "service_suspended"
|
|
1182
|
+
| "rate_limited"
|
|
1183
|
+
| "not_found"
|
|
1184
|
+
| "invalid_request"
|
|
1185
|
+
| "server_error"
|
|
1186
|
+
| "network_error";
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Result of `forms.submit`. Always resolves (never throws for an expected
|
|
1190
|
+
* failure) so the consumer can render success or the exact problem inline.
|
|
1191
|
+
* Discriminate on `ok`.
|
|
1192
|
+
*/
|
|
1193
|
+
export type FormSubmitResult =
|
|
1194
|
+
| {
|
|
1195
|
+
ok: true;
|
|
1196
|
+
/** HTTP status of the accepted submission (200). */
|
|
1197
|
+
status: number;
|
|
1198
|
+
/** Present on a real submit; null/absent on a `dryRun`. */
|
|
1199
|
+
submissionId?: string | null;
|
|
1200
|
+
slug?: string;
|
|
1201
|
+
/** True when this was a `dryRun` — validated but not stored. */
|
|
1202
|
+
dryRun?: boolean;
|
|
1203
|
+
/** Any additional fields BIAB returned (forward-compat). */
|
|
1204
|
+
[key: string]: unknown;
|
|
1205
|
+
}
|
|
1206
|
+
| {
|
|
1207
|
+
ok: false;
|
|
1208
|
+
/** HTTP status, or 0 when the request never reached BIAB. */
|
|
1209
|
+
status: number;
|
|
1210
|
+
reason: FormSubmitFailureReason;
|
|
1211
|
+
/** Human-readable summary, safe to surface to the consumer. */
|
|
1212
|
+
message: string;
|
|
1213
|
+
/** Field-level detail when `reason` is a validation failure. */
|
|
1214
|
+
issues?: FormValidationIssue[];
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
export interface FormsResource {
|
|
1218
|
+
/**
|
|
1219
|
+
* Fetch the form's field definitions. Cached via `biab:forms`
|
|
1220
|
+
* + `biab:forms:<slug>` tags so admin edits invalidate via the
|
|
1221
|
+
* webhook layer.
|
|
1222
|
+
*/
|
|
1223
|
+
schema(slug: string): Promise<FormSchema>;
|
|
1224
|
+
/**
|
|
1225
|
+
* Submit a form. Validates against the schema client-side (via
|
|
1226
|
+
* `validateFormSubmission`) before round-tripping, then posts to BIAB.
|
|
1227
|
+
*
|
|
1228
|
+
* Always resolves to a {@link FormSubmitResult} — it does NOT throw for an
|
|
1229
|
+
* expected failure. Check `result.ok`; on failure, `result.reason` says what
|
|
1230
|
+
* went wrong (bad API key, plan gate, schema mismatch, rate limit, network,
|
|
1231
|
+
* …) and `result.issues` carries field-level detail for validation failures.
|
|
1232
|
+
*/
|
|
1233
|
+
submit(
|
|
1234
|
+
slug: string,
|
|
1235
|
+
data: FormSubmissionData,
|
|
1236
|
+
opts?: {
|
|
1237
|
+
submitterEmail?: string;
|
|
1238
|
+
submitterName?: string;
|
|
1239
|
+
/** Skip client-side validation. Use sparingly. */
|
|
1240
|
+
skipValidate?: boolean;
|
|
1241
|
+
/** Test mode: validate against the live schema on the server but store
|
|
1242
|
+
* nothing and skip all CRM/notification side effects. The result has
|
|
1243
|
+
* `dryRun: true` and no `submissionId`. Ideal for Postman/CI checks. */
|
|
1244
|
+
dryRun?: boolean;
|
|
1245
|
+
/** Provenance — how/where the form was filled (e.g. "facebook",
|
|
1246
|
+
* "google-ads:summer"), an optional referrer, and free-form metadata.
|
|
1247
|
+
* Drives lead-source reporting. Added in 0.9.7. */
|
|
1248
|
+
source?: string;
|
|
1249
|
+
referrer?: string;
|
|
1250
|
+
metadata?: Record<string, unknown>;
|
|
1251
|
+
},
|
|
1252
|
+
): Promise<FormSubmitResult>;
|
|
1253
|
+
/**
|
|
1254
|
+
* Upload a single file for a file-type field on the form.
|
|
1255
|
+
*
|
|
1256
|
+
* 1. Calls the BIAB upload-url endpoint to get a presigned PUT
|
|
1257
|
+
* URL + storageId (the server validates the form, field,
|
|
1258
|
+
* MIME type, and size cap against the org's plan).
|
|
1259
|
+
* 2. PUTs the file bytes directly to R2 — BIAB never sees the
|
|
1260
|
+
* bytes, only the metadata.
|
|
1261
|
+
* 3. Returns a `FormFileValue` to drop into the form's `data`
|
|
1262
|
+
* payload when calling `submit`.
|
|
1263
|
+
*
|
|
1264
|
+
* Throws on plan-gate (`forms.file_uploads` entitlement
|
|
1265
|
+
* missing), field/file mismatch, or transfer failure.
|
|
1266
|
+
*/
|
|
1267
|
+
uploadFile(
|
|
1268
|
+
slug: string,
|
|
1269
|
+
fieldId: string,
|
|
1270
|
+
file: File | Blob,
|
|
1271
|
+
opts?: { filename?: string },
|
|
1272
|
+
): Promise<FormFileValue>;
|
|
1273
|
+
/**
|
|
1274
|
+
* Mint a Stripe intent for a `credit_card` field so `<BiabForm/>` can mount
|
|
1275
|
+
* Stripe Elements. `card_on_file` → a SetupIntent; `deposit` → a PaymentIntent
|
|
1276
|
+
* for the configured amount. The intent is created on the org's connected
|
|
1277
|
+
* account; initialize Stripe with `{ stripeAccount: connectedAccountId }` and
|
|
1278
|
+
* the returned `publishableKey`, then confirm with `clientSecret`. The card
|
|
1279
|
+
* never touches BIAB — only the opaque intent id flows back in the submission.
|
|
1280
|
+
*/
|
|
1281
|
+
paymentIntent(slug: string, fieldId: string): Promise<FormPaymentIntent>;
|
|
1282
|
+
/**
|
|
1283
|
+
* Open booking slots for an auto-mode `schedule` field — the form's assigned
|
|
1284
|
+
* staff's combined real availability over [from, to]. `<BiabForm/>` renders
|
|
1285
|
+
* the instant-booking picker from these; the visitor picks one and the
|
|
1286
|
+
* meeting books on submit. Returns absolute UTC instants (`startAt`); host
|
|
1287
|
+
* identities stay server-side. Empty when the org lacks the scheduling
|
|
1288
|
+
* add-on or the field has no assignees.
|
|
1289
|
+
*/
|
|
1290
|
+
scheduleSlots(
|
|
1291
|
+
slug: string,
|
|
1292
|
+
input: { fieldId: string; from: Date; to: Date },
|
|
1293
|
+
): Promise<{ slots: Array<{ startAt: string }> }>;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** Stripe intent details for mounting Elements on a `credit_card` field. */
|
|
1297
|
+
export interface FormPaymentIntent {
|
|
1298
|
+
mode: "card_on_file" | "deposit";
|
|
1299
|
+
clientSecret: string;
|
|
1300
|
+
intentId: string;
|
|
1301
|
+
publishableKey: string;
|
|
1302
|
+
connectedAccountId: string;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Normalize the server's `issues` array into typed `FormValidationIssue`s. */
|
|
1306
|
+
function mapSubmitIssues(
|
|
1307
|
+
body: Record<string, unknown> | null,
|
|
1308
|
+
): FormValidationIssue[] | undefined {
|
|
1309
|
+
const raw = body?.["issues"];
|
|
1310
|
+
if (!Array.isArray(raw)) return undefined;
|
|
1311
|
+
return raw.map((it) => {
|
|
1312
|
+
const o = (it ?? {}) as Record<string, unknown>;
|
|
1313
|
+
const id =
|
|
1314
|
+
typeof o["id"] === "string"
|
|
1315
|
+
? o["id"]
|
|
1316
|
+
: typeof o["fieldId"] === "string"
|
|
1317
|
+
? o["fieldId"]
|
|
1318
|
+
: "";
|
|
1319
|
+
const label = typeof o["label"] === "string" ? o["label"] : id || "field";
|
|
1320
|
+
const reason = typeof o["reason"] === "string" ? o["reason"] : "type";
|
|
1321
|
+
return {
|
|
1322
|
+
fieldId: id,
|
|
1323
|
+
label,
|
|
1324
|
+
reason,
|
|
1325
|
+
message:
|
|
1326
|
+
typeof o["message"] === "string" ? o["message"] : `${label}: ${reason}`,
|
|
1327
|
+
};
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** Map a non-2xx submit response (status + parsed body) to a failure result. */
|
|
1332
|
+
function mapSubmitResponse(
|
|
1333
|
+
status: number,
|
|
1334
|
+
body: Record<string, unknown> | null,
|
|
1335
|
+
): FormSubmitResult {
|
|
1336
|
+
const message =
|
|
1337
|
+
(typeof body?.["message"] === "string" && body["message"]) ||
|
|
1338
|
+
`Submission failed (${status}).`;
|
|
1339
|
+
const code = typeof body?.["error"] === "string" ? body["error"] : undefined;
|
|
1340
|
+
const gateReason =
|
|
1341
|
+
typeof body?.["reason"] === "string" ? body["reason"] : undefined;
|
|
1342
|
+
const issues = mapSubmitIssues(body);
|
|
1343
|
+
|
|
1344
|
+
let reason: FormSubmitFailureReason;
|
|
1345
|
+
if (status === 401) reason = "unauthorized";
|
|
1346
|
+
else if (status === 403) reason = "forbidden";
|
|
1347
|
+
else if (status === 402)
|
|
1348
|
+
reason =
|
|
1349
|
+
gateReason === "payment_required"
|
|
1350
|
+
? "payment_required"
|
|
1351
|
+
: gateReason === "service_suspended"
|
|
1352
|
+
? "service_suspended"
|
|
1353
|
+
: "plan_required";
|
|
1354
|
+
else if (status === 429) reason = "rate_limited";
|
|
1355
|
+
else if (status === 404) reason = "not_found";
|
|
1356
|
+
else if (status === 400 || status === 422)
|
|
1357
|
+
reason =
|
|
1358
|
+
code === "validation_failed" || issues
|
|
1359
|
+
? "validation_failed"
|
|
1360
|
+
: "invalid_request";
|
|
1361
|
+
else reason = "server_error";
|
|
1362
|
+
|
|
1363
|
+
return {
|
|
1364
|
+
ok: false,
|
|
1365
|
+
status,
|
|
1366
|
+
reason,
|
|
1367
|
+
message,
|
|
1368
|
+
...(issues ? { issues } : {}),
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** Map a thrown error during the pre-submit schema fetch to a failure result. */
|
|
1373
|
+
function mapSubmitThrow(err: unknown): FormSubmitResult {
|
|
1374
|
+
if (err instanceof Error) {
|
|
1375
|
+
// `jsonGet` embeds the HTTP status in its message — recover it if present.
|
|
1376
|
+
const m = err.message.match(/BIAB SDK (\d{3})/);
|
|
1377
|
+
if (m) return mapSubmitResponse(Number(m[1]), null);
|
|
1378
|
+
return {
|
|
1379
|
+
ok: false,
|
|
1380
|
+
status: 0,
|
|
1381
|
+
reason: "network_error",
|
|
1382
|
+
message: `Could not load the form schema: ${err.message}`,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
ok: false,
|
|
1387
|
+
status: 0,
|
|
1388
|
+
reason: "network_error",
|
|
1389
|
+
message: "Could not load the form schema.",
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function createFormsResource(state: InternalState): FormsResource {
|
|
1394
|
+
return {
|
|
1395
|
+
async schema(slug) {
|
|
1396
|
+
// Non-chatbot form read: a form is reachable here when it is **Live**
|
|
1397
|
+
// (`isActive`) — independent of `chatbotAccess`. Embedding a form on
|
|
1398
|
+
// your own page no longer requires opting it into the AI chatbot.
|
|
1399
|
+
const tags = [
|
|
1400
|
+
"biab:forms",
|
|
1401
|
+
`biab:forms:${slug}`,
|
|
1402
|
+
`biab:site:${state.siteId}`,
|
|
1403
|
+
];
|
|
1404
|
+
const path = `forms/${encodeURIComponent(slug)}`;
|
|
1405
|
+
const result = await jsonGet<{
|
|
1406
|
+
id: string;
|
|
1407
|
+
slug: string;
|
|
1408
|
+
title?: string;
|
|
1409
|
+
description?: string | null;
|
|
1410
|
+
fields: FormFieldDef[];
|
|
1411
|
+
settings?: FormRenderSettings;
|
|
1412
|
+
orgIcon?: string;
|
|
1413
|
+
actions?: FormActionPreview[];
|
|
1414
|
+
}>(state, path, tags);
|
|
1415
|
+
return result as FormSchema;
|
|
1416
|
+
},
|
|
1417
|
+
async submit(slug, data, opts) {
|
|
1418
|
+
// 1. Client-side schema check (unless skipped) — return, don't throw,
|
|
1419
|
+
// so the consumer handles it the same way as a server rejection.
|
|
1420
|
+
if (!opts?.skipValidate) {
|
|
1421
|
+
let schema: FormSchema;
|
|
1422
|
+
try {
|
|
1423
|
+
schema = await this.schema(slug);
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
return mapSubmitThrow(err);
|
|
1426
|
+
}
|
|
1427
|
+
const v = validateFormSubmission(schema, data);
|
|
1428
|
+
if (!v.ok) {
|
|
1429
|
+
return {
|
|
1430
|
+
ok: false,
|
|
1431
|
+
status: 0,
|
|
1432
|
+
reason: "client_validation_failed",
|
|
1433
|
+
message: `Form validation failed — ${v.issues
|
|
1434
|
+
.map((i) => `${i.label}: ${i.message}`)
|
|
1435
|
+
.join("; ")}`,
|
|
1436
|
+
issues: v.issues,
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// 2. Round-trip. Map every transport / HTTP outcome to a result.
|
|
1442
|
+
// Live (non-chatbot) submit path — gated on `isActive` only.
|
|
1443
|
+
const url = `${state.baseUrl.replace(/\/$/, "")}/forms/${encodeURIComponent(slug)}`;
|
|
1444
|
+
const init: RequestInit & { next?: unknown } = {
|
|
1445
|
+
method: "POST",
|
|
1446
|
+
headers: {
|
|
1447
|
+
...buildHeaders(state),
|
|
1448
|
+
"Content-Type": "application/json",
|
|
1449
|
+
},
|
|
1450
|
+
body: JSON.stringify({
|
|
1451
|
+
data,
|
|
1452
|
+
submitterEmail: opts?.submitterEmail,
|
|
1453
|
+
submitterName: opts?.submitterName,
|
|
1454
|
+
...(opts?.dryRun ? { dryRun: true } : {}),
|
|
1455
|
+
...(opts?.source ? { source: opts.source } : {}),
|
|
1456
|
+
...(opts?.referrer ? { referrer: opts.referrer } : {}),
|
|
1457
|
+
...(opts?.metadata ? { metadata: opts.metadata } : {}),
|
|
1458
|
+
}),
|
|
1459
|
+
next: { revalidate: 0 },
|
|
1460
|
+
};
|
|
1461
|
+
|
|
1462
|
+
let res: Response;
|
|
1463
|
+
try {
|
|
1464
|
+
res = await state.fetch(url, init);
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
return {
|
|
1467
|
+
ok: false,
|
|
1468
|
+
status: 0,
|
|
1469
|
+
reason: "network_error",
|
|
1470
|
+
message:
|
|
1471
|
+
err instanceof Error
|
|
1472
|
+
? `Could not reach BIAB: ${err.message}`
|
|
1473
|
+
: "Could not reach BIAB.",
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const body = (await res.json().catch(() => null)) as Record<
|
|
1478
|
+
string,
|
|
1479
|
+
unknown
|
|
1480
|
+
> | null;
|
|
1481
|
+
|
|
1482
|
+
if (res.ok) {
|
|
1483
|
+
return {
|
|
1484
|
+
ok: true,
|
|
1485
|
+
status: res.status,
|
|
1486
|
+
...(body ?? {}),
|
|
1487
|
+
} as FormSubmitResult;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
return mapSubmitResponse(res.status, body);
|
|
1491
|
+
},
|
|
1492
|
+
async uploadFile(slug, fieldId, file, opts) {
|
|
1493
|
+
const filename =
|
|
1494
|
+
opts?.filename ?? (file instanceof File ? file.name : "upload.bin");
|
|
1495
|
+
const contentType = file.type || "application/octet-stream";
|
|
1496
|
+
const sizeBytes = file.size;
|
|
1497
|
+
|
|
1498
|
+
// Step 1 — presign. The server validates entitlement
|
|
1499
|
+
// (`forms.file_uploads`), Live form access, field type, MIME
|
|
1500
|
+
// allow-list, and the per-plan / per-field max-size cap.
|
|
1501
|
+
const presignUrl = `${state.baseUrl.replace(/\/$/, "")}/forms/${encodeURIComponent(slug)}/upload-url`;
|
|
1502
|
+
const presignRes = await state.fetch(presignUrl, {
|
|
1503
|
+
method: "POST",
|
|
1504
|
+
headers: {
|
|
1505
|
+
...buildHeaders(state),
|
|
1506
|
+
"Content-Type": "application/json",
|
|
1507
|
+
},
|
|
1508
|
+
body: JSON.stringify({
|
|
1509
|
+
fieldId,
|
|
1510
|
+
filename,
|
|
1511
|
+
contentType,
|
|
1512
|
+
sizeBytes,
|
|
1513
|
+
}),
|
|
1514
|
+
});
|
|
1515
|
+
if (!presignRes.ok) {
|
|
1516
|
+
const text = await presignRes.text().catch(() => "");
|
|
1517
|
+
if (presignRes.status === 403 || presignRes.status === 402) {
|
|
1518
|
+
throw new Error(
|
|
1519
|
+
`BIAB SDK: file upload requires the "forms.file_uploads" entitlement on this org. ${text.slice(0, 200)}`,
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
throw new Error(
|
|
1523
|
+
`BIAB SDK ${presignRes.status} ${presignRes.statusText} on upload-url: ${text.slice(0, 200)}`,
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
const presign = (await presignRes.json()) as {
|
|
1527
|
+
uploadUrl: string;
|
|
1528
|
+
storageId: string;
|
|
1529
|
+
publicUrl: string;
|
|
1530
|
+
expiresAt: string;
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
// Step 2 — PUT bytes directly to R2 (signed URL).
|
|
1534
|
+
const putRes = await state.fetch(presign.uploadUrl, {
|
|
1535
|
+
method: "PUT",
|
|
1536
|
+
headers: { "Content-Type": contentType },
|
|
1537
|
+
body: file,
|
|
1538
|
+
});
|
|
1539
|
+
if (!putRes.ok) {
|
|
1540
|
+
const text = await putRes.text().catch(() => "");
|
|
1541
|
+
throw new Error(
|
|
1542
|
+
`BIAB SDK upload to R2 failed: ${putRes.status} ${putRes.statusText}: ${text.slice(0, 200)}`,
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Step 3 — return the value to drop into form data.
|
|
1547
|
+
return {
|
|
1548
|
+
storageId: presign.storageId,
|
|
1549
|
+
url: presign.publicUrl,
|
|
1550
|
+
name: filename,
|
|
1551
|
+
mimeType: contentType,
|
|
1552
|
+
size: sizeBytes,
|
|
1553
|
+
};
|
|
1554
|
+
},
|
|
1555
|
+
|
|
1556
|
+
async paymentIntent(slug, fieldId) {
|
|
1557
|
+
const url = `${state.baseUrl.replace(/\/$/, "")}/forms/${encodeURIComponent(slug)}/payment-intent`;
|
|
1558
|
+
const res = await state.fetch(url, {
|
|
1559
|
+
method: "POST",
|
|
1560
|
+
headers: {
|
|
1561
|
+
...buildHeaders(state),
|
|
1562
|
+
"Content-Type": "application/json",
|
|
1563
|
+
},
|
|
1564
|
+
body: JSON.stringify({ fieldId }),
|
|
1565
|
+
});
|
|
1566
|
+
if (!res.ok) {
|
|
1567
|
+
const text = await res.text().catch(() => "");
|
|
1568
|
+
throw new Error(
|
|
1569
|
+
`BIAB SDK ${res.status} ${res.statusText} on payment-intent: ${text.slice(0, 200)}`,
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
return (await res.json()) as FormPaymentIntent;
|
|
1573
|
+
},
|
|
1574
|
+
async scheduleSlots(slug, input) {
|
|
1575
|
+
const base = state.baseUrl.replace(/\/$/, "");
|
|
1576
|
+
const qs = new URLSearchParams({
|
|
1577
|
+
fieldId: input.fieldId,
|
|
1578
|
+
from: input.from.toISOString(),
|
|
1579
|
+
to: input.to.toISOString(),
|
|
1580
|
+
});
|
|
1581
|
+
const url = `${base}/forms/${encodeURIComponent(slug)}/schedule-slots?${qs.toString()}`;
|
|
1582
|
+
const res = await state.fetch(url, {
|
|
1583
|
+
method: "GET",
|
|
1584
|
+
headers: buildHeaders(state),
|
|
1585
|
+
});
|
|
1586
|
+
if (!res.ok) {
|
|
1587
|
+
const text = await res.text().catch(() => "");
|
|
1588
|
+
throw new Error(
|
|
1589
|
+
`BIAB SDK ${res.status} ${res.statusText} on schedule-slots: ${text.slice(0, 200)}`,
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
return (await res.json()) as { slots: Array<{ startAt: string }> };
|
|
1593
|
+
},
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// ---------------------------------------------------------------------------
|
|
1598
|
+
// Top-level client
|
|
1599
|
+
// ---------------------------------------------------------------------------
|
|
1600
|
+
|
|
1601
|
+
export interface BiabClient {
|
|
1602
|
+
gallery: GalleryResource;
|
|
1603
|
+
scheduling: SchedulingResource;
|
|
1604
|
+
forms: FormsResource;
|
|
1605
|
+
// Existing class-based resources composed onto the same client so
|
|
1606
|
+
// the consumer only ever sees one entry point. Each class is the
|
|
1607
|
+
// long-form transport for its surface; the consumer pattern is
|
|
1608
|
+
// `client.<resource>.<method>(...)`. Field selection / typed
|
|
1609
|
+
// narrowing lands here surface-by-surface (gallery shipped first).
|
|
1610
|
+
blog: BiabDevBlogClient;
|
|
1611
|
+
reviews: BiabDevReviewsClient;
|
|
1612
|
+
storefront: BiabDevStorefrontClient;
|
|
1613
|
+
cart: BiabDevCartClient;
|
|
1614
|
+
checkout: BiabDevCheckoutClient;
|
|
1615
|
+
coupons: BiabDevCouponsClient;
|
|
1616
|
+
subscriptions: BiabDevSubscriptionsClient;
|
|
1617
|
+
shipping: BiabDevShippingClient;
|
|
1618
|
+
address: BiabDevAddressClient;
|
|
1619
|
+
customerPortal: BiabDevCustomerPortalClient;
|
|
1620
|
+
notifications: BiabDevNotificationsClient;
|
|
1621
|
+
followers: BiabDevFollowersClient;
|
|
1622
|
+
marketing: BiabDevMarketingClient;
|
|
1623
|
+
marketingPages: BiabDevMarketingPagesClient;
|
|
1624
|
+
parallelPages: BiabDevParallelPagesClient;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
export function createBiabClient(opts: CreateBiabClientOptions): BiabClient {
|
|
1628
|
+
if (!opts.apiKey) {
|
|
1629
|
+
throw new Error("createBiabClient: apiKey is required.");
|
|
1630
|
+
}
|
|
1631
|
+
if (!opts.siteId) {
|
|
1632
|
+
throw new Error("createBiabClient: siteId is required.");
|
|
1633
|
+
}
|
|
1634
|
+
const state: InternalState = {
|
|
1635
|
+
apiKey: opts.apiKey,
|
|
1636
|
+
siteId: opts.siteId,
|
|
1637
|
+
baseUrl: opts.baseUrl ?? DEFAULT_BASE_URL,
|
|
1638
|
+
revalidateSeconds: opts.revalidateSeconds ?? DEFAULT_REVALIDATE_SECONDS,
|
|
1639
|
+
fetch: opts.fetch ?? globalThis.fetch.bind(globalThis),
|
|
1640
|
+
siteOrigin: opts.siteOrigin,
|
|
1641
|
+
};
|
|
1642
|
+
const transport = new BiabDevClient({
|
|
1643
|
+
baseUrl: state.baseUrl,
|
|
1644
|
+
apiKey: state.apiKey,
|
|
1645
|
+
...(opts.fetch ? { fetch: opts.fetch } : {}),
|
|
1646
|
+
...(opts.siteOrigin ? { siteOrigin: opts.siteOrigin } : {}),
|
|
1647
|
+
});
|
|
1648
|
+
return {
|
|
1649
|
+
gallery: createGalleryResource(state),
|
|
1650
|
+
scheduling: createSchedulingResource(state),
|
|
1651
|
+
forms: createFormsResource(state),
|
|
1652
|
+
blog: new BiabDevBlogClient(transport),
|
|
1653
|
+
reviews: new BiabDevReviewsClient(transport),
|
|
1654
|
+
storefront: new BiabDevStorefrontClient(transport),
|
|
1655
|
+
cart: new BiabDevCartClient(transport, state.siteId),
|
|
1656
|
+
checkout: new BiabDevCheckoutClient(transport, state.siteId),
|
|
1657
|
+
coupons: new BiabDevCouponsClient(transport),
|
|
1658
|
+
subscriptions: new BiabDevSubscriptionsClient(transport),
|
|
1659
|
+
shipping: new BiabDevShippingClient(transport, state.siteId),
|
|
1660
|
+
address: new BiabDevAddressClient(transport),
|
|
1661
|
+
// CustomerPortal needs an org id binding; when the consumer's API
|
|
1662
|
+
// key already binds an org, pass `null` here (server resolves via
|
|
1663
|
+
// the key). For multi-tenant proxies, override after construction.
|
|
1664
|
+
customerPortal: new BiabDevCustomerPortalClient(transport, null),
|
|
1665
|
+
notifications: new BiabDevNotificationsClient(transport, state.siteId),
|
|
1666
|
+
followers: new BiabDevFollowersClient(transport, state.siteId),
|
|
1667
|
+
marketing: new BiabDevMarketingClient(transport, state.siteId),
|
|
1668
|
+
marketingPages: new BiabDevMarketingPagesClient(transport, state.siteId),
|
|
1669
|
+
parallelPages: new BiabDevParallelPagesClient(transport, state.siteId),
|
|
1670
|
+
};
|
|
1671
|
+
}
|