@businessdash/sdk 0.9.60 → 0.9.70

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.
Files changed (233) hide show
  1. package/README.md +1012 -186
  2. package/assets/biab-sw.js +92 -0
  3. package/dist/account-core/index.d.ts +126 -0
  4. package/dist/account-core/index.d.ts.map +1 -0
  5. package/dist/account-core/index.js +191 -0
  6. package/dist/account-core/index.js.map +1 -0
  7. package/dist/account.d.ts +11 -0
  8. package/dist/account.d.ts.map +1 -0
  9. package/dist/account.js +11 -0
  10. package/dist/account.js.map +1 -0
  11. package/dist/auth-handler.d.ts +1 -1
  12. package/dist/auth-links.d.ts +33 -0
  13. package/dist/auth-links.d.ts.map +1 -0
  14. package/dist/auth-links.js +64 -0
  15. package/dist/auth-links.js.map +1 -0
  16. package/dist/blog-core/index.d.ts +112 -0
  17. package/dist/blog-core/index.d.ts.map +1 -0
  18. package/dist/blog-core/index.js +330 -0
  19. package/dist/blog-core/index.js.map +1 -0
  20. package/dist/blog.d.ts +11 -0
  21. package/dist/blog.d.ts.map +1 -0
  22. package/dist/blog.js +11 -0
  23. package/dist/blog.js.map +1 -0
  24. package/dist/chat-core/index.d.ts +97 -0
  25. package/dist/chat-core/index.d.ts.map +1 -0
  26. package/dist/chat-core/index.js +237 -0
  27. package/dist/chat-core/index.js.map +1 -0
  28. package/dist/chat.d.ts +11 -0
  29. package/dist/chat.d.ts.map +1 -0
  30. package/dist/chat.js +11 -0
  31. package/dist/chat.js.map +1 -0
  32. package/dist/checkout-core/index.d.ts +65 -0
  33. package/dist/checkout-core/index.d.ts.map +1 -0
  34. package/dist/checkout-core/index.js +159 -0
  35. package/dist/checkout-core/index.js.map +1 -0
  36. package/dist/checkout.d.ts +10 -0
  37. package/dist/checkout.d.ts.map +1 -0
  38. package/dist/checkout.js +10 -0
  39. package/dist/checkout.js.map +1 -0
  40. package/dist/cli.js +133 -25
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client.d.ts +567 -1
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +807 -1
  45. package/dist/client.js.map +1 -1
  46. package/dist/contracts.d.ts +3309 -761
  47. package/dist/contracts.d.ts.map +1 -1
  48. package/dist/contracts.js +541 -4
  49. package/dist/contracts.js.map +1 -1
  50. package/dist/data-model-schema.d.ts +1 -1
  51. package/dist/data-model-schema.d.ts.map +1 -1
  52. package/dist/element.d.ts +30 -0
  53. package/dist/element.d.ts.map +1 -1
  54. package/dist/element.js +56 -0
  55. package/dist/element.js.map +1 -1
  56. package/dist/embed-protocol.d.ts +86 -0
  57. package/dist/embed-protocol.d.ts.map +1 -0
  58. package/dist/embed-protocol.js +60 -0
  59. package/dist/embed-protocol.js.map +1 -0
  60. package/dist/framework-support.d.ts +122 -0
  61. package/dist/framework-support.d.ts.map +1 -0
  62. package/dist/framework-support.js +323 -0
  63. package/dist/framework-support.js.map +1 -0
  64. package/dist/index.cjs +1244 -5
  65. package/dist/legal-core/index.cjs +115 -0
  66. package/dist/legal-core/index.d.ts +160 -0
  67. package/dist/legal-core/index.d.ts.map +1 -0
  68. package/dist/legal-core/index.js +220 -0
  69. package/dist/legal-core/index.js.map +1 -0
  70. package/dist/marketing-core/index.d.ts +66 -0
  71. package/dist/marketing-core/index.d.ts.map +1 -0
  72. package/dist/marketing-core/index.js +154 -0
  73. package/dist/marketing-core/index.js.map +1 -0
  74. package/dist/marketing.d.ts +7 -0
  75. package/dist/marketing.d.ts.map +1 -0
  76. package/dist/marketing.js +7 -0
  77. package/dist/marketing.js.map +1 -0
  78. package/dist/mcp-adapters/index.d.ts +135 -0
  79. package/dist/mcp-adapters/index.d.ts.map +1 -0
  80. package/dist/mcp-adapters/index.js +187 -0
  81. package/dist/mcp-adapters/index.js.map +1 -0
  82. package/dist/mcp-adapters.d.ts +7 -0
  83. package/dist/mcp-adapters.d.ts.map +1 -0
  84. package/dist/mcp-adapters.js +7 -0
  85. package/dist/mcp-adapters.js.map +1 -0
  86. package/dist/model3d-core/index.cjs +95 -0
  87. package/dist/model3d-core/index.d.ts +167 -0
  88. package/dist/model3d-core/index.d.ts.map +1 -0
  89. package/dist/model3d-core/index.js +158 -0
  90. package/dist/model3d-core/index.js.map +1 -0
  91. package/dist/notifications-core/index.cjs +315 -0
  92. package/dist/notifications-core/index.d.ts +229 -0
  93. package/dist/notifications-core/index.d.ts.map +1 -0
  94. package/dist/notifications-core/index.js +445 -0
  95. package/dist/notifications-core/index.js.map +1 -0
  96. package/dist/portal-core/index.d.ts +451 -0
  97. package/dist/portal-core/index.d.ts.map +1 -0
  98. package/dist/portal-core/index.js +819 -0
  99. package/dist/portal-core/index.js.map +1 -0
  100. package/dist/portal.d.ts +7 -0
  101. package/dist/portal.d.ts.map +1 -0
  102. package/dist/portal.js +7 -0
  103. package/dist/portal.js.map +1 -0
  104. package/dist/push-core/index.cjs +171 -0
  105. package/dist/push-core/index.d.ts +149 -0
  106. package/dist/push-core/index.d.ts.map +1 -0
  107. package/dist/push-core/index.js +231 -0
  108. package/dist/push-core/index.js.map +1 -0
  109. package/dist/react-marketing.d.ts +1 -17
  110. package/dist/react-marketing.d.ts.map +1 -1
  111. package/dist/react-marketing.js +4 -0
  112. package/dist/react-marketing.js.map +1 -1
  113. package/dist/react.d.ts +294 -101
  114. package/dist/react.d.ts.map +1 -1
  115. package/dist/react.js +302 -246
  116. package/dist/react.js.map +1 -1
  117. package/dist/seed-core/index.d.ts +354 -0
  118. package/dist/seed-core/index.d.ts.map +1 -0
  119. package/dist/seed-core/index.js +420 -0
  120. package/dist/seed-core/index.js.map +1 -0
  121. package/dist/seed.d.ts +7 -0
  122. package/dist/seed.d.ts.map +1 -0
  123. package/dist/seed.js +7 -0
  124. package/dist/seed.js.map +1 -0
  125. package/dist/seo-core/blog.d.ts +94 -0
  126. package/dist/seo-core/blog.d.ts.map +1 -0
  127. package/dist/seo-core/blog.js +149 -0
  128. package/dist/seo-core/blog.js.map +1 -0
  129. package/dist/seo-core/commerce.d.ts +64 -0
  130. package/dist/seo-core/commerce.d.ts.map +1 -0
  131. package/dist/seo-core/commerce.js +87 -0
  132. package/dist/seo-core/commerce.js.map +1 -0
  133. package/dist/seo-core/index.cjs +751 -0
  134. package/dist/seo-core/index.d.ts +226 -0
  135. package/dist/seo-core/index.d.ts.map +1 -0
  136. package/dist/seo-core/index.js +455 -0
  137. package/dist/seo-core/index.js.map +1 -0
  138. package/dist/seo-core/local.d.ts +181 -0
  139. package/dist/seo-core/local.d.ts.map +1 -0
  140. package/dist/seo-core/local.js +193 -0
  141. package/dist/seo-core/local.js.map +1 -0
  142. package/dist/sitemap-core/index.cjs +167 -0
  143. package/dist/sitemap-core/index.d.ts +155 -0
  144. package/dist/sitemap-core/index.d.ts.map +1 -0
  145. package/dist/sitemap-core/index.js +195 -0
  146. package/dist/sitemap-core/index.js.map +1 -0
  147. package/dist/social-core/index.d.ts +74 -0
  148. package/dist/social-core/index.d.ts.map +1 -0
  149. package/dist/social-core/index.js +72 -0
  150. package/dist/social-core/index.js.map +1 -0
  151. package/dist/social.d.ts +7 -0
  152. package/dist/social.d.ts.map +1 -0
  153. package/dist/social.js +7 -0
  154. package/dist/social.js.map +1 -0
  155. package/dist/solid-compat.d.ts +204 -0
  156. package/dist/solid-compat.d.ts.map +1 -0
  157. package/dist/solid-compat.js +221 -0
  158. package/dist/solid-compat.js.map +1 -0
  159. package/dist/store-core/index.d.ts +141 -0
  160. package/dist/store-core/index.d.ts.map +1 -0
  161. package/dist/store-core/index.js +277 -0
  162. package/dist/store-core/index.js.map +1 -0
  163. package/dist/store.d.ts +12 -0
  164. package/dist/store.d.ts.map +1 -0
  165. package/dist/store.js +12 -0
  166. package/dist/store.js.map +1 -0
  167. package/dist/vanilla.d.ts +111 -0
  168. package/dist/vanilla.d.ts.map +1 -1
  169. package/dist/vanilla.js +206 -0
  170. package/dist/vanilla.js.map +1 -1
  171. package/dist/verify-core/index.d.ts +98 -0
  172. package/dist/verify-core/index.d.ts.map +1 -0
  173. package/dist/verify-core/index.js +186 -0
  174. package/dist/verify-core/index.js.map +1 -0
  175. package/dist/verify.d.ts +8 -0
  176. package/dist/verify.d.ts.map +1 -0
  177. package/dist/verify.js +8 -0
  178. package/dist/verify.js.map +1 -0
  179. package/openapi.json +4316 -1313
  180. package/package.json +121 -9
  181. package/src/account-core/index.ts +309 -0
  182. package/src/account.ts +11 -0
  183. package/src/angular/headless.ts +519 -0
  184. package/src/angular/index.ts +3 -0
  185. package/src/auth-links.ts +76 -0
  186. package/src/blog-core/index.ts +423 -0
  187. package/src/blog.ts +11 -0
  188. package/src/chat-core/index.ts +335 -0
  189. package/src/chat.ts +11 -0
  190. package/src/checkout-core/index.ts +225 -0
  191. package/src/checkout.ts +10 -0
  192. package/src/cli.ts +184 -28
  193. package/src/client.ts +998 -0
  194. package/src/contracts.ts +730 -11
  195. package/src/data-model-schema.ts +1 -1
  196. package/src/element.ts +102 -0
  197. package/src/embed-protocol.ts +100 -0
  198. package/src/framework-support.ts +440 -0
  199. package/src/legal-core/index.ts +276 -0
  200. package/src/marketing-core/index.ts +209 -0
  201. package/src/marketing.ts +7 -0
  202. package/src/mcp-adapters/index.ts +264 -0
  203. package/src/mcp-adapters.ts +7 -0
  204. package/src/model3d-core/index.ts +248 -0
  205. package/src/notifications-core/index.ts +650 -0
  206. package/src/portal-core/index.ts +1564 -0
  207. package/src/portal.ts +7 -0
  208. package/src/push-core/index.ts +335 -0
  209. package/src/qwik.tsx +602 -2
  210. package/src/react-marketing.tsx +5 -0
  211. package/src/react.tsx +434 -319
  212. package/src/seed-core/index.ts +735 -0
  213. package/src/seed.ts +7 -0
  214. package/src/seo-core/blog.ts +216 -0
  215. package/src/seo-core/commerce.ts +138 -0
  216. package/src/seo-core/index.ts +605 -0
  217. package/src/seo-core/local.ts +333 -0
  218. package/src/sitemap-core/index.ts +296 -0
  219. package/src/social-core/index.ts +103 -0
  220. package/src/social.ts +7 -0
  221. package/src/solid-compat.tsx +347 -0
  222. package/src/solid-surfaces.tsx +1311 -0
  223. package/src/solid.tsx +82 -60
  224. package/src/store-core/index.ts +426 -0
  225. package/src/store.ts +12 -0
  226. package/src/svelte/SocialLinks.svelte +84 -0
  227. package/src/svelte/headless.ts +435 -0
  228. package/src/svelte/index.ts +9 -0
  229. package/src/vanilla.ts +367 -0
  230. package/src/verify-core/index.ts +288 -0
  231. package/src/verify.ts +8 -0
  232. package/src/vue/headless.ts +527 -0
  233. package/src/vue/index.ts +3 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `@businessdash/sdk/legal` — the org's legal pages, rendered on their site.
3
+ *
4
+ * Privacy policy, terms, refund policy: written once in the BusinessDash
5
+ * dashboard, and appearing on the org's own domain without anyone authoring a
6
+ * page for them.
7
+ *
8
+ * ## The design constraint that decides everything: your page must win
9
+ *
10
+ * A consumer who writes their own `/privacy` must keep it, and their build must
11
+ * not fail because we also wanted that path. There is a tempting wrong answer —
12
+ * generate `app/privacy/page.tsx` at install time — and it fails badly: two
13
+ * files claiming one route is a hard build error in Next, Remix, Nuxt, Astro,
14
+ * SvelteKit and TanStack Start alike. It turns "I wrote my own privacy page"
15
+ * into a broken deploy.
16
+ *
17
+ * So **this SDK never claims a literal path.** It gives you a resolver to mount
18
+ * in a CATCH-ALL route, and every one of those frameworks resolves a static
19
+ * segment before a catch-all:
20
+ *
21
+ * | Framework | Your page | The catch-all |
22
+ * |--------------|------------------------|----------------------------|
23
+ * | Next (app) | `app/privacy/page.tsx` | `app/[...slug]/page.tsx` |
24
+ * | Remix / RR7 | `routes/privacy.tsx` | `routes/$.tsx` |
25
+ * | Astro | `pages/privacy.astro` | `pages/[...slug].astro` |
26
+ * | Nuxt | `pages/privacy.vue` | `pages/[...slug].vue` |
27
+ * | SvelteKit | `routes/privacy/+page` | `routes/[...rest]/+page` |
28
+ * | TanStack | `routes/privacy.tsx` | `routes/$.tsx` |
29
+ *
30
+ * Write your own page and it simply shadows ours. No conflict, no build error,
31
+ * no configuration, and nothing to remove — the override is the framework's own
32
+ * routing precedence rather than anything we invented.
33
+ *
34
+ * The corollary: `resolveLegalPage()` returns `null` for a slug with no
35
+ * document, and your catch-all must then fall through to its normal 404. It is
36
+ * a resolver, not a router, precisely so it cannot swallow paths it has no
37
+ * business answering.
38
+ *
39
+ * ## What you render
40
+ *
41
+ * `contentHtml` is sanitised server-side when the org saves it, so it is safe
42
+ * to inject. The page is deliberately unstyled markup: legal pages should look
43
+ * like the rest of the site, and an SDK that shipped its own typography would
44
+ * be one more thing to override. `renderLegalPageHtml()` produces semantic HTML
45
+ * with stable `data-biab-legal-*` hooks you can style, or ignore entirely and
46
+ * build your own from the same data.
47
+ *
48
+ * @module
49
+ */
50
+
51
+ import type {
52
+ LegalDocument,
53
+ LegalDocumentSummary,
54
+ LegalListResponse,
55
+ LegalPresentation,
56
+ } from "../contracts.js";
57
+
58
+ /** The subset of the SDK client this module needs. */
59
+ export type LegalClientLike = {
60
+ legal: {
61
+ list(): Promise<LegalListResponse>;
62
+ get(slug: string): Promise<LegalDocument>;
63
+ };
64
+ };
65
+
66
+ /**
67
+ * Turn a catch-all's path segments into a slug.
68
+ *
69
+ * Accepts what each framework actually hands you — an array of segments, a
70
+ * joined path, with or without leading slashes — because normalising this at
71
+ * every call site is how the two sides end up disagreeing about `/privacy/`
72
+ * versus `privacy`.
73
+ */
74
+ export function legalSlugFrom(input: string | string[] | undefined): string {
75
+ if (!input) return "";
76
+ const joined = Array.isArray(input) ? input.join("/") : input;
77
+ return joined
78
+ .trim()
79
+ .toLowerCase()
80
+ .replace(/^\/+|\/+$/g, "")
81
+ .replace(/\/{2,}/g, "/");
82
+ }
83
+
84
+ export type ResolveLegalPageOptions = {
85
+ client: LegalClientLike;
86
+ /** Whatever the catch-all captured. */
87
+ slug: string | string[] | undefined;
88
+ };
89
+
90
+ /**
91
+ * The document at this path, or null if there isn't one.
92
+ *
93
+ * **Null means fall through to your own 404.** Returning an "empty legal page"
94
+ * would put a bare heading reading "Privacy Policy" on every unmatched URL on
95
+ * the site, which is worse than a 404 in every way that matters — it is wrong
96
+ * for users, wrong for crawlers, and hides real routing mistakes.
97
+ *
98
+ * A network failure also returns null rather than throwing. A catch-all runs on
99
+ * every unmatched request, and an exception there takes down paths that have
100
+ * nothing to do with legal pages; a 404 during an outage is recoverable, a
101
+ * 500 on every unknown URL is not.
102
+ */
103
+ export async function resolveLegalPage(
104
+ options: ResolveLegalPageOptions,
105
+ ): Promise<LegalDocument | null> {
106
+ const slug = legalSlugFrom(options.slug);
107
+ if (!slug) return null;
108
+ try {
109
+ return await options.client.legal.get(slug);
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The links an org wants in their footer.
117
+ *
118
+ * Filtered on `showInFooter`, which is separate from published on purpose: an
119
+ * org may need a document to exist at a URL they hand to a processor without
120
+ * putting it in front of every visitor.
121
+ */
122
+ export function legalFooterLinks(
123
+ list: LegalListResponse | LegalDocumentSummary[],
124
+ options: { basePath?: string } = {},
125
+ ): Array<{ href: string; label: string }> {
126
+ const items = Array.isArray(list) ? list : list.items;
127
+ const base = (options.basePath ?? "").replace(/\/+$/, "");
128
+ return items
129
+ .filter((doc) => doc.showInFooter)
130
+ .map((doc) => ({ href: `${base}/${doc.slug}`, label: doc.title }));
131
+ }
132
+
133
+ /** Escape text destined for markup. Titles are plain text, not HTML. */
134
+ function escapeHtml(value: string): string {
135
+ return value
136
+ .replace(/&/g, "&amp;")
137
+ .replace(/</g, "&lt;")
138
+ .replace(/>/g, "&gt;")
139
+ .replace(/"/g, "&quot;");
140
+ }
141
+
142
+ /**
143
+ * "Last updated" text, or null when the org never set a date.
144
+ *
145
+ * Uses `effectiveDate` in preference to `updatedAt`: a typo fix bumps
146
+ * `updatedAt` and should not tell every reader the terms changed. The org
147
+ * decides when a change was material by setting the effective date.
148
+ *
149
+ * ## Formatted in UTC, deliberately
150
+ *
151
+ * The dashboard stores a calendar date as midnight UTC, so formatting in the
152
+ * READER's zone shifts it a day for everyone west of UTC — an effective date of
153
+ * 1 August renders as "July 31" in New York. On a marketing page that is a
154
+ * cosmetic bug; on a document whose whole purpose is to say when terms changed,
155
+ * it is a wrong date shown to roughly half the audience.
156
+ */
157
+ export function legalLastUpdated(
158
+ document: Pick<LegalDocument, "effectiveDate">,
159
+ locale?: string,
160
+ ): string | null {
161
+ if (!document.effectiveDate) return null;
162
+ const date = new Date(document.effectiveDate);
163
+ if (Number.isNaN(date.getTime())) return null;
164
+ return date.toLocaleDateString(locale, {
165
+ year: "numeric",
166
+ month: "long",
167
+ day: "numeric",
168
+ timeZone: "UTC",
169
+ });
170
+ }
171
+
172
+ export type RenderLegalPageOptions = {
173
+ /** Wrapped around everything. Defaults to `biab-legal`. */
174
+ className?: string;
175
+ /** Locale for the "Last updated" line. */
176
+ locale?: string;
177
+ /** Where the logo links to. Defaults to `/`. */
178
+ homeHref?: string;
179
+ };
180
+
181
+ /**
182
+ * Semantic markup for one document: header, title, body, footer.
183
+ *
184
+ * Unstyled by design. Every element carries a stable `data-biab-legal-*`
185
+ * attribute so a stylesheet can reach it, and none carries a class you would
186
+ * have to fight. If you want a different structure, read the same fields off
187
+ * the document and build your own — this is a convenience, not a requirement.
188
+ *
189
+ * `contentHtml`, `headerHtml` and `footerHtml` are inserted as-is because they
190
+ * were sanitised when the org saved them. The title and logo URL are escaped
191
+ * here, since they are plain values that have never been through a sanitiser.
192
+ */
193
+ export function renderLegalPageHtml(
194
+ document: LegalDocument,
195
+ options: RenderLegalPageOptions = {},
196
+ ): string {
197
+ const presentation = document.presentation;
198
+ const className = options.className ?? "biab-legal";
199
+ const homeHref = options.homeHref ?? "/";
200
+ const updated = legalLastUpdated(document, options.locale);
201
+
202
+ const logo = (where: "header" | "footer") => {
203
+ const show =
204
+ where === "header"
205
+ ? presentation.showLogoInHeader
206
+ : presentation.showLogoInFooter;
207
+ if (!show || !presentation.logoUrl) return "";
208
+ return (
209
+ `<a href="${escapeHtml(homeHref)}" data-biab-legal-logo-link>` +
210
+ `<img src="${escapeHtml(presentation.logoUrl)}" alt="" data-biab-legal-logo="${where}" />` +
211
+ "</a>"
212
+ );
213
+ };
214
+
215
+ const parts = [
216
+ `<article class="${escapeHtml(className)}" data-biab-legal>`,
217
+ "<header data-biab-legal-header>",
218
+ logo("header"),
219
+ presentation.headerHtml ?? "",
220
+ `<h1 data-biab-legal-title>${escapeHtml(document.title)}</h1>`,
221
+ updated
222
+ ? `<p data-biab-legal-updated>Last updated: ${escapeHtml(updated)}</p>`
223
+ : "",
224
+ "</header>",
225
+ `<div data-biab-legal-body>${document.contentHtml}</div>`,
226
+ "<footer data-biab-legal-footer>",
227
+ presentation.footerHtml ?? "",
228
+ logo("footer"),
229
+ "</footer>",
230
+ "</article>",
231
+ ];
232
+ return parts.filter(Boolean).join("");
233
+ }
234
+
235
+ /**
236
+ * Page metadata for a legal document.
237
+ *
238
+ * `noindex` is deliberately NOT set. Legal pages should be indexable: people
239
+ * search for "acme refund policy", and app stores and payment processors check
240
+ * that the URL you gave them resolves to something a crawler can see.
241
+ *
242
+ * Feed this to `@businessdash/sdk/seo-core`'s adapters, or spread it into your
243
+ * framework's own metadata export.
244
+ */
245
+ export function legalPageSeo(
246
+ document: LegalDocument,
247
+ options: { siteName?: string; canonical?: string } = {},
248
+ ): {
249
+ title: string;
250
+ description: string;
251
+ canonical?: string;
252
+ openGraph: { title: string; type: "article"; url?: string };
253
+ } {
254
+ const title = options.siteName
255
+ ? `${document.title} — ${options.siteName}`
256
+ : document.title;
257
+ return {
258
+ title,
259
+ // Built from the title rather than the body: the first sentence of a
260
+ // privacy policy is boilerplate ("This policy explains how…") and makes
261
+ // a description indistinguishable from every other company's.
262
+ description: options.siteName
263
+ ? `The ${document.title.toLowerCase()} for ${options.siteName}.`
264
+ : `Read our ${document.title.toLowerCase()}.`,
265
+ ...(options.canonical === undefined
266
+ ? {}
267
+ : { canonical: options.canonical }),
268
+ openGraph: {
269
+ title,
270
+ type: "article",
271
+ ...(options.canonical === undefined ? {} : { url: options.canonical }),
272
+ },
273
+ };
274
+ }
275
+
276
+ export type { LegalDocument, LegalDocumentSummary, LegalPresentation };
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `@businessdash/sdk/marketing` — the headless marketing-page controller.
3
+ *
4
+ * Marketing pages were React-only, and the reason was accidental: the logic is
5
+ * a cache, a fetch and a status flag. This lifts it out so every binding can
6
+ * render a schema-driven page.
7
+ *
8
+ * ## Why the cache matters here specifically
9
+ *
10
+ * A site can have many marketing pages, and a visitor reads one. Fetching all
11
+ * of them to render one is the obvious mistake, so `open()` pulls a single page
12
+ * on demand, and `prefetch()` exists for the case where you know what is coming
13
+ * next (a nav hover, a likely next step) and want it warm before the click.
14
+ *
15
+ * The SDK client keeps its own cache; this controller does not duplicate it. It
16
+ * tracks WHICH page is showing and whether a request is in flight — the cache
17
+ * itself stays one layer down, where every framework already shares it.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ import type {
23
+ GetSiteMarketingPageResponse,
24
+ ListSiteMarketingPagesResponse,
25
+ } from "../contracts.js";
26
+
27
+ export type MarketingCoreClient = {
28
+ marketingPages: {
29
+ list(): Promise<ListSiteMarketingPagesResponse>;
30
+ get(
31
+ pageKey?: string,
32
+ options?: { skipCache?: boolean },
33
+ ): Promise<GetSiteMarketingPageResponse>;
34
+ prefetch(pageKey?: string): Promise<GetSiteMarketingPageResponse>;
35
+ };
36
+ };
37
+
38
+ export type MarketingStatus = "idle" | "loading" | "ready" | "error";
39
+
40
+ /** Field names are STABLE and load-bearing for every framework binding. */
41
+ export type MarketingSnapshot = {
42
+ status: MarketingStatus;
43
+ /** Every page on the site — keys and titles, not bodies. */
44
+ pages: ListSiteMarketingPagesResponse["pages"];
45
+ /** The page currently open, or `null`. */
46
+ page: GetSiteMarketingPageResponse | null;
47
+ /** Which key `page` belongs to. */
48
+ pageKey: string | null;
49
+ error: Error | null;
50
+ pending: { list: boolean; page: boolean };
51
+ };
52
+
53
+ export type MarketingController = {
54
+ snapshot(): MarketingSnapshot;
55
+ subscribe(callback: (snapshot: MarketingSnapshot) => void): () => void;
56
+ /** Fetch the page index. */
57
+ load(): Promise<void>;
58
+ /** Fetch and show one page. */
59
+ open(pageKey?: string, options?: { skipCache?: boolean }): Promise<void>;
60
+ /** Warm a page's cache without showing it. Failures are swallowed. */
61
+ prefetch(pageKey?: string): Promise<void>;
62
+ close(): void;
63
+ destroy(): void;
64
+ };
65
+
66
+ export type CreateMarketingControllerOptions = {
67
+ client: MarketingCoreClient;
68
+ onError?: (error: Error) => void;
69
+ };
70
+
71
+ function toError(cause: unknown, fallback: string): Error {
72
+ return cause instanceof Error ? cause : new Error(fallback);
73
+ }
74
+
75
+ export function createMarketingController(
76
+ options: CreateMarketingControllerOptions,
77
+ ): MarketingController {
78
+ const { client, onError } = options;
79
+
80
+ let destroyed = false;
81
+ /** Guards against a slow page landing after the visitor moved on. */
82
+ let pageToken = 0;
83
+ const subscribers = new Set<(s: MarketingSnapshot) => void>();
84
+
85
+ const state: {
86
+ status: MarketingStatus;
87
+ pages: ListSiteMarketingPagesResponse["pages"];
88
+ page: GetSiteMarketingPageResponse | null;
89
+ pageKey: string | null;
90
+ error: Error | null;
91
+ pending: MarketingSnapshot["pending"];
92
+ } = {
93
+ status: "idle",
94
+ pages: [],
95
+ page: null,
96
+ pageKey: null,
97
+ error: null,
98
+ pending: { list: false, page: false },
99
+ };
100
+
101
+ let cached: MarketingSnapshot | null = null;
102
+
103
+ function snapshot(): MarketingSnapshot {
104
+ if (!cached) {
105
+ cached = {
106
+ status: state.status,
107
+ pages: state.pages,
108
+ page: state.page,
109
+ pageKey: state.pageKey,
110
+ error: state.error,
111
+ pending: state.pending,
112
+ };
113
+ }
114
+ return cached;
115
+ }
116
+
117
+ function notify(): void {
118
+ if (destroyed) return;
119
+ cached = null;
120
+ const next = snapshot();
121
+ for (const cb of subscribers) cb(next);
122
+ }
123
+
124
+ function fail(cause: unknown, fallback: string): void {
125
+ const error = toError(cause, fallback);
126
+ state.error = error;
127
+ if (state.status !== "ready") state.status = "error";
128
+ onError?.(error);
129
+ }
130
+
131
+ return {
132
+ snapshot,
133
+
134
+ subscribe(callback) {
135
+ subscribers.add(callback);
136
+ return () => {
137
+ subscribers.delete(callback);
138
+ };
139
+ },
140
+
141
+ async load() {
142
+ if (destroyed) return;
143
+ state.pending.list = true;
144
+ state.error = null;
145
+ if (state.status === "idle") state.status = "loading";
146
+ notify();
147
+ try {
148
+ const res = await client.marketingPages.list();
149
+ if (destroyed) return;
150
+ state.pages = res.pages;
151
+ state.status = "ready";
152
+ } catch (cause) {
153
+ if (!destroyed) fail(cause, "Failed to load the page index.");
154
+ } finally {
155
+ if (!destroyed) {
156
+ state.pending.list = false;
157
+ notify();
158
+ }
159
+ }
160
+ },
161
+
162
+ async open(pageKey = "home", openOptions) {
163
+ if (destroyed) return;
164
+ const token = ++pageToken;
165
+ state.pending.page = true;
166
+ state.error = null;
167
+ state.pageKey = pageKey;
168
+ notify();
169
+ try {
170
+ const page = await client.marketingPages.get(pageKey, openOptions);
171
+ if (destroyed || token !== pageToken) return;
172
+ state.page = page;
173
+ state.status = "ready";
174
+ } catch (cause) {
175
+ if (destroyed || token !== pageToken) return;
176
+ fail(cause, `Failed to load the "${pageKey}" page.`);
177
+ } finally {
178
+ if (!destroyed && token === pageToken) {
179
+ state.pending.page = false;
180
+ notify();
181
+ }
182
+ }
183
+ },
184
+
185
+ async prefetch(pageKey = "home") {
186
+ if (destroyed) return;
187
+ try {
188
+ await client.marketingPages.prefetch(pageKey);
189
+ } catch {
190
+ // Prefetching is a guess about where the visitor is going. Being
191
+ // wrong, or failing, must never surface as an error on the page
192
+ // they are actually reading.
193
+ }
194
+ },
195
+
196
+ close() {
197
+ if (destroyed) return;
198
+ pageToken += 1; // orphan any in-flight page request
199
+ state.page = null;
200
+ state.pageKey = null;
201
+ notify();
202
+ },
203
+
204
+ destroy() {
205
+ destroyed = true;
206
+ subscribers.clear();
207
+ },
208
+ };
209
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@businessdash/sdk/marketing` — the framework-agnostic marketing-page core.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ export * from "./marketing-core/index.js";