@wildo-ai/saas-website 1.1.5 → 1.1.7

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 (225) hide show
  1. package/dist/esm/astro/blog-post-bridge.d.ts +9 -0
  2. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  3. package/dist/esm/astro/blog-post-bridge.js +10 -5
  4. package/dist/esm/astro/blog-post-bridge.js.map +1 -1
  5. package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -1
  6. package/dist/esm/astro/bridge-runtime.d.ts +25 -6
  7. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  8. package/dist/esm/astro/bridge-runtime.js +10 -6
  9. package/dist/esm/astro/bridge-runtime.js.map +1 -1
  10. package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -1
  11. package/dist/esm/astro/headers-renderer.d.ts.map +1 -1
  12. package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -1
  13. package/dist/esm/astro/label-pack-loader.d.ts +4 -4
  14. package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
  15. package/dist/esm/astro/label-pack-loader.js +9 -4
  16. package/dist/esm/astro/label-pack-loader.js.map +1 -1
  17. package/dist/esm/astro/llms-txt-server.d.ts.map +1 -1
  18. package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -1
  19. package/dist/esm/astro/robots-renderer.d.ts +9 -8
  20. package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
  21. package/dist/esm/astro/robots-renderer.js +15 -15
  22. package/dist/esm/astro/robots-renderer.js.map +1 -1
  23. package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -1
  24. package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -1
  25. package/dist/esm/astro/website-page-head.renderer.d.ts +1 -1
  26. package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
  27. package/dist/esm/astro/website-page-head.renderer.js +2 -1
  28. package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
  29. package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -1
  30. package/dist/esm/astro/website-site-context.d.ts +5 -2
  31. package/dist/esm/astro/website-site-context.d.ts.map +1 -1
  32. package/dist/esm/astro/website-site-context.js.map +1 -1
  33. package/dist/esm/astro-island.d.ts.map +1 -1
  34. package/dist/esm/astro.d.ts.map +1 -1
  35. package/dist/esm/companion-exports.d.ts +8 -15
  36. package/dist/esm/companion-exports.d.ts.map +1 -1
  37. package/dist/esm/companion-exports.js +8 -15
  38. package/dist/esm/companion-exports.js.map +1 -1
  39. package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -1
  40. package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -1
  41. package/dist/esm/config/define-website-config.d.ts +3 -2
  42. package/dist/esm/config/define-website-config.d.ts.map +1 -1
  43. package/dist/esm/config/define-website-config.js +10 -10
  44. package/dist/esm/config/define-website-config.js.map +1 -1
  45. package/dist/esm/config/index.d.ts.map +1 -1
  46. package/dist/esm/config/load-website-config.d.ts +6 -0
  47. package/dist/esm/config/load-website-config.d.ts.map +1 -1
  48. package/dist/esm/config/load-website-config.js +18 -9
  49. package/dist/esm/config/load-website-config.js.map +1 -1
  50. package/dist/esm/config/load-website-pricing-catalog.d.ts +13 -0
  51. package/dist/esm/config/load-website-pricing-catalog.d.ts.map +1 -0
  52. package/dist/esm/config/load-website-pricing-catalog.js +34 -0
  53. package/dist/esm/config/load-website-pricing-catalog.js.map +1 -0
  54. package/dist/esm/config/load-website-site-context.d.ts +53 -0
  55. package/dist/esm/config/load-website-site-context.d.ts.map +1 -0
  56. package/dist/esm/config/load-website-site-context.js +98 -0
  57. package/dist/esm/config/load-website-site-context.js.map +1 -0
  58. package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
  59. package/dist/esm/config-loader.d.ts +2 -0
  60. package/dist/esm/config-loader.d.ts.map +1 -1
  61. package/dist/esm/config-loader.js +2 -0
  62. package/dist/esm/config-loader.js.map +1 -1
  63. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
  64. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
  65. package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
  66. package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
  67. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +11 -0
  68. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
  69. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +15 -4
  70. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  71. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +14 -2
  72. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
  73. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +34 -4
  74. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
  75. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
  76. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
  77. package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
  78. package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
  79. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -1
  80. package/dist/esm/core/consent/WebsiteConsentBanner.js +11 -10
  81. package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -1
  82. package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -1
  83. package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -1
  84. package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -1
  85. package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -1
  86. package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
  87. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +25 -5
  88. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  89. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  90. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
  91. package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
  92. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
  93. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
  94. package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
  95. package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
  96. package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
  97. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
  98. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
  99. package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
  100. package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
  101. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  102. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
  103. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
  104. package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
  105. package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
  106. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
  107. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
  108. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
  109. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
  110. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +15 -5
  111. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
  112. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +39 -24
  113. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
  114. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
  115. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
  116. package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
  117. package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
  118. package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
  119. package/dist/esm/core/factories/define-website-section.d.ts +3 -3
  120. package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
  121. package/dist/esm/core/factories/define-website-section.js +3 -3
  122. package/dist/esm/core/factories/define-website-section.js.map +1 -1
  123. package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
  124. package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
  125. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  126. package/dist/esm/core/layouts/WebsitePageLayout.js +10 -7
  127. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  128. package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
  129. package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
  130. package/dist/esm/index.d.ts +30 -65
  131. package/dist/esm/index.d.ts.map +1 -1
  132. package/dist/esm/index.js +30 -65
  133. package/dist/esm/index.js.map +1 -1
  134. package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
  135. package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
  136. package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
  137. package/dist/esm/mdx/index.d.ts.map +1 -1
  138. package/dist/esm/mdx.d.ts.map +1 -1
  139. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
  140. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
  141. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
  142. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
  143. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
  144. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -1
  145. package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
  146. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
  147. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
  148. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
  149. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
  150. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
  151. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +9 -8
  152. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
  153. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
  154. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
  155. package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
  156. package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
  157. package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
  158. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
  159. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
  160. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
  161. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
  162. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
  163. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
  164. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
  165. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
  166. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
  167. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
  168. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
  169. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
  170. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
  171. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
  172. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
  173. package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
  174. package/dist/tsconfig.build.tsbuildinfo +1 -1
  175. package/package.json +5 -5
  176. package/src/__tests__/bundle-isolation.test.ts +19 -22
  177. package/src/astro/__tests__/blog-post-bridge.test.tsx +18 -2
  178. package/src/astro/__tests__/bridge-runtime.test.tsx +21 -3
  179. package/src/astro/__tests__/label-pack-loader.test.ts +7 -4
  180. package/src/astro/blog-post-bridge.tsx +19 -5
  181. package/src/astro/bridge-runtime.tsx +35 -11
  182. package/src/astro/label-pack-loader.ts +8 -4
  183. package/src/astro/robots-renderer.ts +15 -15
  184. package/src/astro/website-page-head.renderer.ts +2 -2
  185. package/src/astro/website-site-context.ts +5 -2
  186. package/src/companion-exports.ts +8 -15
  187. package/src/config/__tests__/website-build-origins.test.ts +52 -0
  188. package/src/config/define-website-config.ts +10 -10
  189. package/src/config/load-website-config.ts +25 -14
  190. package/src/config/load-website-pricing-catalog.ts +52 -0
  191. package/src/config/load-website-site-context.ts +145 -0
  192. package/src/config-loader.ts +2 -0
  193. package/src/core/__tests__/contexts.test.tsx +20 -1
  194. package/src/core/__tests__/website-consent.test.tsx +2 -1
  195. package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
  196. package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
  197. package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
  198. package/src/core/anonymous-session/inbound-contact-form.schema.ts +16 -4
  199. package/src/core/anonymous-session/website-anonymous-session-client.ts +39 -4
  200. package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
  201. package/src/core/consent/WebsiteConsentBanner.tsx +11 -10
  202. package/src/core/contexts/WebsiteRuntimeContext.tsx +25 -5
  203. package/src/core/contexts/useWebsiteRuntime.ts +33 -2
  204. package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
  205. package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
  206. package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
  207. package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
  208. package/src/core/external-providers/provider-component-registry.website.ts +115 -0
  209. package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
  210. package/src/core/external-providers/useWebsiteProviderScripts.ts +56 -37
  211. package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
  212. package/src/core/factories/define-website-section.ts +3 -3
  213. package/src/core/layouts/WebsitePageLayout.tsx +11 -6
  214. package/src/index.ts +31 -65
  215. package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
  216. package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
  217. package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
  218. package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
  219. package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +12 -0
  220. package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
  221. package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
  222. package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
  223. package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
  224. package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
  225. package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +7 -5
@@ -0,0 +1,139 @@
1
+ /**
2
+ * #1735 — the contact form answers the application's submission challenge.
3
+ *
4
+ * The client is a double (it records what the form sent and answers like the engine); the challenge helper, the
5
+ * proof-of-work solver and the refusal detection are the real ones.
6
+ */
7
+ import React from 'react';
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
10
+ import '@testing-library/jest-dom';
11
+ import {
12
+ ProofOfWorkAlgorithm,
13
+ SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH,
14
+ SubmissionChallengeAction,
15
+ SubmissionChallengeRefusalCode,
16
+ SubmissionChallengeResponseKind,
17
+ WildoHeaderKeys,
18
+ hashProofOfWorkCandidate,
19
+ type ProofOfWorkChallenge,
20
+ } from '@wildo-ai/saas-models/public-runtime';
21
+
22
+ import { InboundContactForm, type InboundContactFormLabels } from '../InboundContactForm';
23
+ import { WebsiteAnonymousRequestError, type WebsiteAnonymousSessionClient } from '../website-anonymous-session-client';
24
+ import type { WebsiteSubmissionChallengeSource } from '../website-submission-challenge';
25
+
26
+ afterEach(() => { cleanup(); });
27
+
28
+ /** Every label, as the section passes them (#1986: all are required). */
29
+ const LABELS: InboundContactFormLabels = {
30
+ emailLabel: 'email-label',
31
+ firstNameLabel: 'first-name-label',
32
+ lastNameLabel: 'last-name-label',
33
+ phoneLabel: 'phone-label',
34
+ marketingConsentLabel: 'marketing-consent-label',
35
+ submitLabel: 'submit-label',
36
+ submittingLabel: 'submitting-label',
37
+ successMessage: 'success-label',
38
+ errorMessage: 'error-label',
39
+ challengeRefusedMessage: 'refused-label',
40
+ invalidEmailMessage: 'invalid-email-label',
41
+ };
42
+
43
+ async function smallChallenge(): Promise<ProofOfWorkChallenge> {
44
+ const salt = `${'a'.repeat(24)}?expires=9999999999`;
45
+ return { algorithm: ProofOfWorkAlgorithm.SHA_256, salt, challenge: await hashProofOfWorkCandidate(salt, 7), maxNumber: 20, signature: 'b'.repeat(64) };
46
+ }
47
+
48
+ function refusal(code: SubmissionChallengeRefusalCode): WebsiteAnonymousRequestError {
49
+ return new WebsiteAnonymousRequestError(400, JSON.stringify({ error: { type: 'VALIDATION', code } }));
50
+ }
51
+
52
+ function clientDouble(answers: Array<'ok' | WebsiteAnonymousRequestError>) {
53
+ const posts: Array<Record<string, string> | undefined> = [];
54
+ const gets: string[] = [];
55
+ const client = {
56
+ postAnonymous: vi.fn(async (_path: string, _body: unknown, headers?: Record<string, string>) => {
57
+ posts.push(headers);
58
+ const next = answers.shift() ?? 'ok';
59
+ if (next !== 'ok') throw next;
60
+ return {};
61
+ }),
62
+ getPublic: vi.fn(async (path: string) => {
63
+ gets.push(path);
64
+ return smallChallenge();
65
+ }),
66
+ } as unknown as WebsiteAnonymousSessionClient;
67
+ return { client, posts, gets };
68
+ }
69
+
70
+ function submit(): void {
71
+ fireEvent.change(screen.getByRole('textbox', { name: /email/i }), { target: { value: 'visitor@example.test' } });
72
+ fireEvent.click(screen.getByRole('button', { name: LABELS.submitLabel }));
73
+ }
74
+
75
+ const answerIn = (headers: Record<string, string> | undefined) => JSON.parse(headers?.[WildoHeaderKeys.SUBMISSION_CHALLENGE_RESPONSE] ?? 'null');
76
+
77
+ describe('InboundContactForm — the submission challenge (#1735)', () => {
78
+ it('sends no answer on a site that declares no captcha', async () => {
79
+ const { client, posts, gets } = clientDouble(['ok']);
80
+ render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: false }} labels={LABELS} />);
81
+ submit();
82
+ await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
83
+ expect(posts).toEqual([undefined]);
84
+ expect(gets).toEqual([]);
85
+ });
86
+
87
+ it('sends the RUNNING vendor\'s token for this form\'s action', async () => {
88
+ const { client, posts } = clientDouble(['ok']);
89
+ const execute = vi.fn(async () => 'vendor-token');
90
+ const source: WebsiteSubmissionChallengeSource = { captchaDeclared: true, runningCaptcha: { providerRef: 'recaptcha', sink: { execute } } };
91
+ render(<InboundContactForm client={client} challengeSource={source} labels={LABELS} />);
92
+ submit();
93
+ await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
94
+ expect(execute).toHaveBeenCalledWith(SubmissionChallengeAction.INBOUND_CONTACT);
95
+ expect(answerIn(posts[0])).toEqual({ kind: SubmissionChallengeResponseKind.PROVIDER_TOKEN, providerRef: 'recaptcha', token: 'vendor-token' });
96
+ });
97
+
98
+ it('solves the proof of work when the captcha is declared but not running (no consent)', async () => {
99
+ const { client, posts, gets } = clientDouble(['ok']);
100
+ render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: true }} labels={LABELS} />);
101
+ submit();
102
+ await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
103
+ expect(gets).toEqual([SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH]);
104
+ expect(answerIn(posts[0])).toMatchObject({ kind: SubmissionChallengeResponseKind.PROOF_OF_WORK, number: 7 });
105
+ });
106
+
107
+ it('answers a refused vendor token ONCE more with the proof of work', async () => {
108
+ const { client, posts } = clientDouble([refusal(SubmissionChallengeRefusalCode.REFUSED), 'ok']);
109
+ const source: WebsiteSubmissionChallengeSource = { captchaDeclared: true, runningCaptcha: { providerRef: 'recaptcha', sink: { execute: async () => 'low-score' } } };
110
+ render(<InboundContactForm client={client} challengeSource={source} labels={LABELS} />);
111
+ submit();
112
+ await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
113
+ expect(posts.map((headers) => answerIn(headers).kind)).toEqual([SubmissionChallengeResponseKind.PROVIDER_TOKEN, SubmissionChallengeResponseKind.PROOF_OF_WORK]);
114
+ });
115
+
116
+ it('answers a site that did not know the backend protects the form (REQUIRED) with the proof of work', async () => {
117
+ const { client, posts } = clientDouble([refusal(SubmissionChallengeRefusalCode.REQUIRED), 'ok']);
118
+ render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: false }} labels={LABELS} />);
119
+ submit();
120
+ await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
121
+ expect(posts[0]).toBeUndefined();
122
+ expect(answerIn(posts[1])).toMatchObject({ kind: SubmissionChallengeResponseKind.PROOF_OF_WORK });
123
+ });
124
+
125
+ it('shows the challenge refusal, in its own words, when the proof of work is refused too', async () => {
126
+ const { client } = clientDouble([refusal(SubmissionChallengeRefusalCode.REFUSED), refusal(SubmissionChallengeRefusalCode.REFUSED)]);
127
+ render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: true }} labels={LABELS} />);
128
+ submit();
129
+ expect(await screen.findByRole('alert')).toHaveTextContent('refused-label');
130
+ });
131
+
132
+ it('does not retry any other failure', async () => {
133
+ const { client, posts } = clientDouble([new WebsiteAnonymousRequestError(500, '{}')]);
134
+ render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: false }} labels={LABELS} />);
135
+ submit();
136
+ expect(await screen.findByRole('alert')).toHaveTextContent('error-label');
137
+ expect(posts).toHaveLength(1);
138
+ });
139
+ });
@@ -23,18 +23,30 @@ import { z } from "zod";
23
23
 
24
24
  import { ConsentPurpose } from "@wildo-ai/saas-models/public-runtime";
25
25
 
26
+ /**
27
+ * Length ceilings of the optional text fields. Exported so the form puts the SAME numbers on its inputs as
28
+ * `maxLength`: the browser then stops a visitor at the ceiling, and a length failure is not something the form ever
29
+ * has to word (#1986).
30
+ */
31
+ export const INBOUND_CONTACT_NAME_MAX_LENGTH = 200;
32
+ export const INBOUND_CONTACT_PHONE_MAX_LENGTH = 64;
33
+
26
34
  /**
27
35
  * The wire payload POSTed to `/api/v1/inbound-contacts`. `privacy` matches the
28
36
  * resource's `InboundContactPrivacySchema` shape (a nested consent object) so the
29
37
  * backend accepts it verbatim.
38
+ *
39
+ * No issue carries a message (#1986). A visitor reads the application's own words, which the form takes from its
40
+ * section's label pack (`InboundContactFormLabels.invalidEmailMessage`); a message written here would be English in
41
+ * every locale, and nobody authoring the pack could find it.
30
42
  */
31
43
  export const InboundContactFormSchema = z.object({
32
44
  // Zod 4: top-level `z.email()` (not the deprecated `z.string().email()`), matching
33
45
  // the resource schema. An empty string fails it, so it also covers "required".
34
- email: z.email("Enter a valid email address"),
35
- firstName: z.string().trim().max(200).optional(),
36
- lastName: z.string().trim().max(200).optional(),
37
- phone: z.string().trim().max(64).optional(),
46
+ email: z.email(),
47
+ firstName: z.string().trim().max(INBOUND_CONTACT_NAME_MAX_LENGTH).optional(),
48
+ lastName: z.string().trim().max(INBOUND_CONTACT_NAME_MAX_LENGTH).optional(),
49
+ phone: z.string().trim().max(INBOUND_CONTACT_PHONE_MAX_LENGTH).optional(),
38
50
  /**
39
51
  * OPTIONAL marketing opt-in — maps into the resource's `privacy.consentedPurposes` set.
40
52
  * It is the only privacy field on this form, and it gates nothing: sending the form is the
@@ -58,6 +58,19 @@ export class WebsiteAnonymousRequestError extends Error {
58
58
  super(`Anonymous request failed with status ${status}`);
59
59
  this.name = 'WebsiteAnonymousRequestError';
60
60
  }
61
+
62
+ /**
63
+ * The refusal's stable `error.code` — what a client branches on (the engine hoists an error's `context.code` there) —
64
+ * or `undefined` when the body is not the engine's error envelope or carries no code.
65
+ */
66
+ get errorCode(): string | undefined {
67
+ try {
68
+ const code = (JSON.parse(this.bodyText) as { error?: { code?: unknown } }).error?.code;
69
+ return typeof code === 'string' ? code : undefined;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
61
74
  }
62
75
 
63
76
  export class WebsiteAnonymousSessionClient {
@@ -108,8 +121,24 @@ export class WebsiteAnonymousSessionClient {
108
121
  * service-name headers, captures any returned session token, and returns the parsed
109
122
  * JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.
110
123
  */
111
- async postAnonymous<TResponse = unknown>(path: string, body: unknown): Promise<TResponse> {
112
- return this.requestAnonymous<TResponse>('POST', path, body);
124
+ async postAnonymous<TResponse = unknown>(path: string, body: unknown, extraHeaders?: Readonly<Record<string, string>>): Promise<TResponse> {
125
+ return this.requestAnonymous<TResponse>('POST', path, body, extraHeaders);
126
+ }
127
+
128
+ /**
129
+ * GET a PUBLIC endpoint — one that needs no session (a proof-of-work challenge, #1735). Starts no anonymous session
130
+ * and sends no session header or cookie: asking for a challenge must not mint an identity for the visitor.
131
+ */
132
+ async getPublic<TResponse = unknown>(path: string): Promise<TResponse> {
133
+ const response = await fetch(`${this.apiBaseUrl}${path}`, {
134
+ method: 'GET',
135
+ headers: { [WildoHeaderKeys.FRONTEND_SERVICE_NAME]: this.frontendServiceName },
136
+ credentials: 'omit',
137
+ });
138
+ if (!response.ok) {
139
+ throw new WebsiteAnonymousRequestError(response.status, await safeReadText(response));
140
+ }
141
+ return (await response.json()) as TResponse;
113
142
  }
114
143
 
115
144
  /**
@@ -122,12 +151,18 @@ export class WebsiteAnonymousSessionClient {
122
151
  * the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside
123
152
  * `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.
124
153
  */
125
- async requestAnonymous<TResponse = unknown>(method: string, path: string, body: unknown): Promise<TResponse> {
154
+ async requestAnonymous<TResponse = unknown>(
155
+ method: string,
156
+ path: string,
157
+ body: unknown,
158
+ /** Request-specific headers, such as a public form's challenge answer (#1735). Never overrides the session headers. */
159
+ extraHeaders?: Readonly<Record<string, string>>,
160
+ ): Promise<TResponse> {
126
161
  this.ensureSessionStarted();
127
162
 
128
163
  const response = await fetch(`${this.apiBaseUrl}${path}`, {
129
164
  method,
130
- headers: this.buildHeaders(),
165
+ headers: { ...extraHeaders, ...this.buildHeaders() },
131
166
  body: JSON.stringify(body),
132
167
  // Carry the parent-domain `__wildo_anon_session` cookie to the API so the
133
168
  // session is continuous across the apex/app/api origins.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The answer a public website form sends with its submission when the application protects it with a captcha (#1735), to
3
+ * any operation that declares a submission challenge (#1985).
4
+ *
5
+ * Two answers, one header (`x-submission-challenge-response`), chosen in the visitor's browser:
6
+ *
7
+ * - the consented captcha vendor's TOKEN, when its browser part is running on the page (it starts only once the
8
+ * visitor grants the purpose the vendor rests on — Google reCAPTCHA rests on ANALYTICS);
9
+ * - otherwise the engine's PROOF OF WORK: a challenge fetched from the backend and solved here. Consent-exempt —
10
+ * nothing is stored in the browser and nothing reaches a third party — so a visitor who declined still submits.
11
+ *
12
+ * The backend requires one of the two whenever it declares a captcha provider, and refuses a submission with neither.
13
+ * A site that declares none sends nothing. A refused vendor token is retried ONCE with the proof of work (a low score or
14
+ * an unreachable vendor must not lock a real person out); see {@link isSubmissionChallengeRefusal}.
15
+ */
16
+ import { useContext, useMemo } from 'react';
17
+ import {
18
+ BUILTIN_PROVIDER_CAPABILITY,
19
+ SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH,
20
+ SubmissionChallengeRefusalCode,
21
+ SubmissionChallengeResponseKind,
22
+ encodeSubmissionChallengeResponse,
23
+ isFrontendCaptchaSink,
24
+ solveProofOfWorkChallenge,
25
+ type FrontendCaptchaSink,
26
+ type ProofOfWorkChallenge,
27
+ } from '@wildo-ai/saas-models/public-runtime';
28
+
29
+ import { WebsiteRuntimeContext } from '../contexts/WebsiteRuntimeContext';
30
+ import { useWebsiteProviderSdkActivations } from '../external-providers/website-provider-sdk-activations';
31
+ import { WebsiteAnonymousRequestError, type WebsiteAnonymousSessionClient } from './website-anonymous-session-client';
32
+
33
+ /** @wildo_source:part:start saas.website.submission-challenge facet:layer:frontend facet:audience:app-developer */
34
+ /** What a form knows about the page's captcha when it submits. */
35
+ export interface WebsiteSubmissionChallengeSource {
36
+ /** This website declares a captcha provider, so its public forms answer a challenge. */
37
+ readonly captchaDeclared: boolean;
38
+ /** The captcha vendor RUNNING on the page — present only once the visitor consented and it started. */
39
+ readonly runningCaptcha?: { readonly providerRef: string; readonly sink: FrontendCaptchaSink };
40
+ }
41
+
42
+ /** The challenge source of the current page: its declared captcha provider and, if started, its running client. */
43
+ export function useWebsiteSubmissionChallengeSource(): WebsiteSubmissionChallengeSource {
44
+ // Read without `useWebsiteRuntime`'s throw: a form rendered outside a page (a test, a storybook) simply has no captcha.
45
+ const runtime = useContext(WebsiteRuntimeContext);
46
+ const running = useWebsiteProviderSdkActivations();
47
+ const captchaDeclared = runtime?.frontendProviderRegistry?.findProviderByProviderCapability(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA) !== undefined;
48
+ return useMemo(() => {
49
+ const captcha = running.find((entry) => entry.providerCapabilities.includes(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA)
50
+ && isFrontendCaptchaSink(entry.activation.handle));
51
+ return {
52
+ captchaDeclared,
53
+ ...(captcha === undefined ? {} : { runningCaptcha: { providerRef: captcha.providerRef, sink: captcha.activation.handle as FrontendCaptchaSink } }),
54
+ };
55
+ }, [captchaDeclared, running]);
56
+ }
57
+
58
+ /**
59
+ * The encoded answer to send, or `undefined` when this site asks for none. `proofOfWorkOnly` skips the vendor (the
60
+ * retry after a refused token). Throws only when the proof-of-work challenge cannot be fetched or solved; the caller
61
+ * then shows its ordinary failure and does not submit unanswered (`InboundContactForm` shows its `errorMessage`).
62
+ */
63
+ export async function obtainSubmissionChallengeAnswer(params: {
64
+ readonly source: WebsiteSubmissionChallengeSource;
65
+ /**
66
+ * The action the operation the form posts to DECLARES in its `submissionChallenge` (#1985): the engine's
67
+ * `SubmissionChallengeAction.INBOUND_CONTACT` for `/inbound-contacts`, your own action for your own operation. A token
68
+ * minted for any other action is refused.
69
+ */
70
+ readonly action: string;
71
+ readonly client: Pick<WebsiteAnonymousSessionClient, 'getPublic'>;
72
+ readonly proofOfWorkOnly?: boolean;
73
+ readonly signal?: AbortSignal;
74
+ }): Promise<string | undefined> {
75
+ const { source, action, client, proofOfWorkOnly = false, signal } = params;
76
+ if (!proofOfWorkOnly && source.runningCaptcha !== undefined) {
77
+ const token = await source.runningCaptcha.sink.execute(action);
78
+ if (token !== undefined) {
79
+ return encodeSubmissionChallengeResponse({
80
+ kind: SubmissionChallengeResponseKind.PROVIDER_TOKEN,
81
+ providerRef: source.runningCaptcha.providerRef,
82
+ token,
83
+ });
84
+ }
85
+ }
86
+ if (!source.captchaDeclared && !proofOfWorkOnly) return undefined;
87
+ const challenge = await client.getPublic<ProofOfWorkChallenge>(SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH);
88
+ const answer = await solveProofOfWorkChallenge(challenge, { signal });
89
+ if (answer === undefined) throw new Error('The proof-of-work challenge could not be solved.');
90
+ return encodeSubmissionChallengeResponse(answer);
91
+ }
92
+
93
+ /** Whether a failed submission was refused at its challenge — the one refusal a form answers again, with the proof of work. */
94
+ export function isSubmissionChallengeRefusal(error: unknown): boolean {
95
+ if (!(error instanceof WebsiteAnonymousRequestError)) return false;
96
+ const code = error.errorCode;
97
+ return code === SubmissionChallengeRefusalCode.REQUIRED || code === SubmissionChallengeRefusalCode.REFUSED;
98
+ }
99
+ /** @wildo_source:part:end saas.website.submission-challenge */
@@ -1,6 +1,7 @@
1
1
  import React, { useEffect, useId, useState, type ReactNode } from 'react';
2
2
  import type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
3
3
 
4
+ import { websiteDesignTokenCssVar } from '../../schemas/design-tokens/website-design-tokens.shared.schemas';
4
5
  import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';
5
6
  import { WebsiteConsentStatus } from './WebsiteConsentContext';
6
7
  import { useWebsiteConsent } from './useWebsiteConsent';
@@ -49,7 +50,7 @@ const ROW_STYLE: React.CSSProperties = {
49
50
  columnGap: '0.625rem',
50
51
  alignItems: 'center',
51
52
  padding: '0.5rem 0.75rem',
52
- borderBottom: '1px solid var(--website-colors-border, #ccc)',
53
+ borderBottom: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
53
54
  };
54
55
 
55
56
  const ROW_DESCRIPTION_STYLE: React.CSSProperties = { margin: 0, gridColumn: 2, fontSize: '0.75rem', opacity: 0.8 };
@@ -79,10 +80,10 @@ function PurposeChoice({ purpose, checked, onChange }: { purpose: ConsentPurpose
79
80
 
80
81
  const BUTTON_STYLE: React.CSSProperties = {
81
82
  padding: '0.5rem 1rem',
82
- borderRadius: 'var(--website-radii-md, 0.375rem)',
83
- border: '1px solid var(--website-colors-border, #ccc)',
84
- background: 'var(--website-colors-background, #fff)',
85
- color: 'var(--website-colors-foreground, #000)',
83
+ borderRadius: websiteDesignTokenCssVar('radii', 'md', '0.375rem'),
84
+ border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
85
+ background: websiteDesignTokenCssVar('colors', 'background', '#fff'),
86
+ color: websiteDesignTokenCssVar('colors', 'foreground', '#000'),
86
87
  cursor: 'pointer',
87
88
  };
88
89
 
@@ -141,10 +142,10 @@ export function WebsiteConsentBanner(): ReactNode {
141
142
  display: 'flex',
142
143
  flexDirection: 'column',
143
144
  gap: '0.625rem',
144
- background: 'var(--website-colors-surface, #fff)',
145
- color: 'var(--website-colors-surface-foreground, #000)',
146
- border: '1px solid var(--website-colors-border, #ccc)',
147
- borderRadius: 'var(--website-radii-lg, 0.5rem)',
145
+ background: websiteDesignTokenCssVar('colors', 'surface', '#fff'),
146
+ color: websiteDesignTokenCssVar('colors', 'surfaceForeground', '#000'),
147
+ border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
148
+ borderRadius: websiteDesignTokenCssVar('radii', 'lg', '0.5rem'),
148
149
  }}
149
150
  >
150
151
  <h2 id={headingId} style={{ margin: 0, fontSize: '1.125rem' }}>{title}</h2>
@@ -154,7 +155,7 @@ export function WebsiteConsentBanner(): ReactNode {
154
155
  nothing — the same vacuous pin the app tier already had to repair. */}
155
156
  <div
156
157
  data-website-consent-rows=""
157
- style={{ minHeight: 0, flex: 1, overflowY: 'auto', border: '1px solid var(--website-colors-border, #ccc)', borderRadius: 'var(--website-radii-md, 0.375rem)' }}
158
+ style={{ minHeight: 0, flex: 1, overflowY: 'auto', border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`, borderRadius: websiteDesignTokenCssVar('radii', 'md', '0.375rem') }}
158
159
  >
159
160
  {/*
160
161
  * The always-active row, first, carrying a CHECKED and DISABLED box. It is not a purpose the
@@ -1,9 +1,10 @@
1
1
  import React, { createContext, type ComponentType, type ReactNode } from 'react';
2
2
 
3
- import type { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
3
+ import type { AvailableLanguage, PublicPricingCatalog } from '@wildo-ai/saas-models/public-runtime';
4
4
 
5
5
  import type { WebsiteDesignTokens } from '../../schemas/design-tokens/website-design-tokens.shared.schemas';
6
6
  import type { FrontendWebsiteProviderRegistry } from '../external-providers/frontend-provider-registry.website';
7
+ import type { WebsiteProviderComponentRegistry } from '../external-providers/provider-component-registry.website';
7
8
  import type { WebsiteLabelKey } from '../../schemas/label-keys/website-label-key.schemas';
8
9
  import type { WebsiteNavigationConfig } from '../../schemas/manifests/website-root-config.shared.schemas';
9
10
  import type { LabelPack } from '../../schemas/validators/label-pack.validator';
@@ -12,10 +13,10 @@ import type { LabelPack } from '../../schemas/validators/label-pack.validator';
12
13
  * @wildo_source:part:start saas.website.runtime.context facet:layer:core facet:family:website
13
14
  *
14
15
  * `WebsiteRuntimeContext` — site-wide runtime state for the marketing
15
- * surface. Created **once per page render** by the host adapter
16
- * (Phase 5 `WebsitePageBridge.astro` reads `WebsiteRootConfig` and the
17
- * resolved label pack from disk and calls
18
- * `<WebsiteRuntimeContextProvider value={…}>` around the page tree).
16
+ * surface. Created **once per page render** by the host adapter: the
17
+ * page's `.astro` frontmatter loads the site context and the resolved label
18
+ * pack, and its bridge island (`createWebsitePageBridgeIsland`) calls
19
+ * `<WebsiteRuntimeContextProvider value={…}>` around the page tree.
19
20
  *
20
21
  * **Why "runtime" and not "config"**: the values here are the
21
22
  * locale-resolved subset of `WebsiteRootConfig` — only the active
@@ -152,6 +153,14 @@ export interface WebsiteRuntimeContextValue {
152
153
  * catalog for analytics / captcha / browser-monitoring slices.
153
154
  */
154
155
  frontendProviderRegistry?: FrontendWebsiteProviderRegistry;
156
+ /**
157
+ * Components the live providers contribute, keyed `capability:providerRef` (#1703) — the same
158
+ * registry the application builds, from the same `components` declaration. Built by the page bridge
159
+ * once per island from `frontendProviderRegistry`; read through `useWebsiteProviderComponent` and
160
+ * rendered by `WebsiteProviderComponent`. Optional for the same reason as the registry above: a
161
+ * provider-free site, or a test runtime, has none, and every slot then renders nothing.
162
+ */
163
+ providerComponentRegistry?: WebsiteProviderComponentRegistry;
155
164
  /**
156
165
  * Replaces the framework's visitor consent panel (#534). Reads `useWebsiteConsent()`; see
157
166
  * `WebsiteConsentBanner` for the three properties a replacement must keep. Absent uses the default.
@@ -173,6 +182,17 @@ export interface WebsiteRuntimeContextValue {
173
182
  * silently failing to submit.
174
183
  */
175
184
  apiOrigin?: string;
185
+ /**
186
+ * The application's public pricing catalogue (#1835), derived at build time from the billing
187
+ * catalogue the backend sells from — so a pricing section states the prices the application
188
+ * actually charges instead of a hand copy of them. Node-loaded
189
+ * (`loadWebsitePricingCatalog` on `/config-loader`) and carried as an island prop for the
190
+ * reason `appOrigin` above is. Read it with `useWebsitePricingCatalog()`.
191
+ *
192
+ * Optional because only pricing pages need it; a section that reads it on a page that did not
193
+ * pass it fails loudly rather than rendering no plans.
194
+ */
195
+ pricingCatalog?: PublicPricingCatalog;
176
196
  }
177
197
 
178
198
  /**
@@ -1,5 +1,7 @@
1
1
  import { useContext } from 'react';
2
2
 
3
+ import type { PublicPricingCatalog } from '@wildo-ai/saas-models/public-runtime';
4
+
3
5
  import {
4
6
  WebsiteRuntimeContext,
5
7
  type WebsiteRuntimeContextValue,
@@ -25,10 +27,39 @@ export function useWebsiteRuntime(): WebsiteRuntimeContextValue {
25
27
  if (value === null) {
26
28
  throw new Error(
27
29
  '[useWebsiteRuntime] must be called inside <WebsiteRuntimeContextProvider>. ' +
28
- 'In Astro pages this is provided by `WebsitePageBridge.astro` — make sure ' +
29
- 'your page imports the bridge instead of rendering React components directly.',
30
+ 'In Astro pages this is provided by the page\'s bridge island (`createWebsitePageBridgeIsland` ' +
31
+ 'from @wildo-ai/saas-website/astro-island) — make sure the page renders its bridge instead of ' +
32
+ 'rendering React components directly.',
30
33
  );
31
34
  }
32
35
  return value;
33
36
  }
34
37
  /** @wildo_source:part:end saas.website.use-runtime.hook */
38
+
39
+ /**
40
+ * @wildo_source:part:start saas.website.use-pricing-catalog.hook facet:layer:core facet:family:website facet:audience:app-developer
41
+ *
42
+ * `useWebsitePricingCatalog()` — the application's public pricing catalogue, for a pricing section.
43
+ *
44
+ * The plans, their prices, which one is recommended, how much of each price may be stated, and which
45
+ * are announced but not yet on sale — all derived at build time from the billing catalogue the
46
+ * backend sells from. A pricing section renders from THIS and never keeps its own copy of a price:
47
+ * a copy is a price that will disagree with the one the application charges.
48
+ *
49
+ * Throws when the page did not pass the catalogue to its bridge island: a pricing section with no
50
+ * plans would silently state that the application sells nothing. The page's `.astro` frontmatter
51
+ * loads it with `loadWebsitePricingCatalog()` from `@wildo-ai/saas-website/config-loader` and passes
52
+ * it as the island's `pricingCatalog` prop.
53
+ */
54
+ export function useWebsitePricingCatalog(): PublicPricingCatalog {
55
+ const { pricingCatalog } = useWebsiteRuntime();
56
+ if (pricingCatalog === undefined) {
57
+ throw new Error(
58
+ '[useWebsitePricingCatalog] this page did not pass a pricing catalogue to its bridge island. In the page\'s '
59
+ + '`.astro` frontmatter, load it with `await loadWebsitePricingCatalog()` (the @wildo-ai/saas-website/config-loader '
60
+ + 'subpath) and pass `pricingCatalog={pricingCatalog}` to the island.',
61
+ );
62
+ }
63
+ return pricingCatalog;
64
+ }
65
+ /** @wildo_source:part:end saas.website.use-pricing-catalog.hook */