@wildo-ai/saas-website 1.1.4 → 1.1.6

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 (265) hide show
  1. package/dist/esm/astro/blog-post-bridge.d.ts +23 -20
  2. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  3. package/dist/esm/astro/blog-post-bridge.js +13 -6
  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 +55 -26
  7. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  8. package/dist/esm/astro/bridge-runtime.js +21 -7
  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 +14 -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 +20 -10
  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 +10 -5
  59. package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
  60. package/dist/esm/config/wildo-website-config.schemas.js +10 -5
  61. package/dist/esm/config/wildo-website-config.schemas.js.map +1 -1
  62. package/dist/esm/config-loader.d.ts +2 -0
  63. package/dist/esm/config-loader.d.ts.map +1 -1
  64. package/dist/esm/config-loader.js +2 -0
  65. package/dist/esm/config-loader.js.map +1 -1
  66. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
  67. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
  68. package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
  69. package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
  70. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +14 -3
  71. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
  72. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +28 -13
  73. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  74. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +24 -1
  75. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
  76. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +46 -3
  77. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
  78. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
  79. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
  80. package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
  81. package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
  82. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts +6 -0
  83. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -0
  84. package/dist/esm/core/consent/WebsiteConsentBanner.js +129 -0
  85. package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -0
  86. package/dist/esm/core/consent/WebsiteConsentContext.d.ts +85 -0
  87. package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -0
  88. package/dist/esm/core/consent/WebsiteConsentContext.js +89 -0
  89. package/dist/esm/core/consent/WebsiteConsentContext.js.map +1 -0
  90. package/dist/esm/core/consent/WebsiteConsentGate.d.ts +27 -0
  91. package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -0
  92. package/dist/esm/core/consent/WebsiteConsentGate.js +9 -0
  93. package/dist/esm/core/consent/WebsiteConsentGate.js.map +1 -0
  94. package/dist/esm/core/consent/useWebsiteConsent.d.ts +10 -0
  95. package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -0
  96. package/dist/esm/core/consent/useWebsiteConsent.js +17 -0
  97. package/dist/esm/core/consent/useWebsiteConsent.js.map +1 -0
  98. package/dist/esm/core/consent/website-consent-storage.d.ts +22 -0
  99. package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -0
  100. package/dist/esm/core/consent/website-consent-storage.js +102 -0
  101. package/dist/esm/core/consent/website-consent-storage.js.map +1 -0
  102. package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
  103. package/dist/esm/core/contexts/WebsitePageContext.js.map +1 -1
  104. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +33 -6
  105. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  106. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  107. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
  108. package/dist/esm/core/contexts/WebsiteSectionContext.js.map +1 -1
  109. package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
  110. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
  111. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
  112. package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
  113. package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
  114. package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
  115. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
  116. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
  117. package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
  118. package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
  119. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +1 -2
  120. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  121. package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -1
  122. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
  123. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
  124. package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
  125. package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
  126. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
  127. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
  128. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
  129. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
  130. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +43 -10
  131. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
  132. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +92 -29
  133. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
  134. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
  135. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
  136. package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
  137. package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
  138. package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
  139. package/dist/esm/core/factories/define-website-section.d.ts +3 -3
  140. package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
  141. package/dist/esm/core/factories/define-website-section.js +3 -3
  142. package/dist/esm/core/factories/define-website-section.js.map +1 -1
  143. package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
  144. package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
  145. package/dist/esm/core/layouts/WebsitePageLayout.d.ts +21 -1
  146. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  147. package/dist/esm/core/layouts/WebsitePageLayout.js +39 -8
  148. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  149. package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
  150. package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
  151. package/dist/esm/index.d.ts +36 -65
  152. package/dist/esm/index.d.ts.map +1 -1
  153. package/dist/esm/index.js +36 -65
  154. package/dist/esm/index.js.map +1 -1
  155. package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
  156. package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
  157. package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
  158. package/dist/esm/mdx/index.d.ts.map +1 -1
  159. package/dist/esm/mdx.d.ts.map +1 -1
  160. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
  161. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
  162. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
  163. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
  164. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
  165. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts +61 -0
  166. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -0
  167. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js +73 -0
  168. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js.map +1 -0
  169. package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
  170. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
  171. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
  172. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
  173. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
  174. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
  175. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +6 -5
  176. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
  177. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
  178. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
  179. package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
  180. package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
  181. package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
  182. package/dist/esm/schemas/sections/website-section-category.shared.js.map +1 -1
  183. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
  184. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
  185. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
  186. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
  187. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
  188. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
  189. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
  190. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
  191. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
  192. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
  193. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
  194. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
  195. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
  196. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
  197. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
  198. package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
  199. package/dist/tsconfig.build.tsbuildinfo +1 -1
  200. package/package.json +6 -29
  201. package/src/__tests__/bundle-isolation.test.ts +19 -22
  202. package/src/astro/__tests__/blog-post-bridge.test.tsx +58 -1
  203. package/src/astro/__tests__/bridge-runtime.test.tsx +44 -1
  204. package/src/astro/__tests__/label-pack-loader.test.ts +29 -1
  205. package/src/astro/blog-post-bridge.tsx +36 -26
  206. package/src/astro/bridge-runtime.tsx +79 -33
  207. package/src/astro/label-pack-loader.ts +12 -4
  208. package/src/astro/robots-renderer.ts +15 -15
  209. package/src/astro/website-page-head.renderer.ts +2 -2
  210. package/src/astro/website-site-context.ts +5 -2
  211. package/src/companion-exports.ts +8 -15
  212. package/src/config/__tests__/website-build-origins.test.ts +52 -0
  213. package/src/config/define-website-config.ts +10 -10
  214. package/src/config/load-website-config.ts +27 -15
  215. package/src/config/load-website-pricing-catalog.ts +52 -0
  216. package/src/config/load-website-site-context.ts +145 -0
  217. package/src/config/wildo-website-config.schemas.ts +10 -5
  218. package/src/config-loader.ts +2 -0
  219. package/src/core/__tests__/WebsitePageLayout.test.tsx +1 -1
  220. package/src/core/__tests__/contexts.test.tsx +20 -1
  221. package/src/core/__tests__/website-consent.test.tsx +312 -0
  222. package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
  223. package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
  224. package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
  225. package/src/core/anonymous-session/__tests__/inbound-contact-form.schema.test.ts +11 -4
  226. package/src/core/anonymous-session/inbound-contact-form.schema.ts +30 -13
  227. package/src/core/anonymous-session/website-anonymous-session-client.ts +52 -3
  228. package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
  229. package/src/core/consent/WebsiteConsentBanner.tsx +223 -0
  230. package/src/core/consent/WebsiteConsentContext.tsx +164 -0
  231. package/src/core/consent/WebsiteConsentGate.tsx +34 -0
  232. package/src/core/consent/useWebsiteConsent.ts +18 -0
  233. package/src/core/consent/website-consent-storage.ts +116 -0
  234. package/src/core/contexts/WebsiteRuntimeContext.tsx +33 -6
  235. package/src/core/contexts/useWebsiteRuntime.ts +33 -2
  236. package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
  237. package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
  238. package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
  239. package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
  240. package/src/core/external-providers/frontend-provider-registry.website.ts +1 -2
  241. package/src/core/external-providers/provider-component-registry.website.ts +115 -0
  242. package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
  243. package/src/core/external-providers/useWebsiteProviderScripts.ts +114 -40
  244. package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
  245. package/src/core/factories/define-website-section.ts +3 -3
  246. package/src/core/layouts/WebsitePageLayout.tsx +75 -7
  247. package/src/index.ts +38 -65
  248. package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
  249. package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
  250. package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
  251. package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
  252. package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +83 -0
  253. package/src/schemas/label-keys/website-consent-label-keys.schemas.ts +78 -0
  254. package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
  255. package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
  256. package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
  257. package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
  258. package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
  259. package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +7 -5
  260. package/dist/esm/core/external-providers/provider-scripts.website.d.ts +0 -68
  261. package/dist/esm/core/external-providers/provider-scripts.website.d.ts.map +0 -1
  262. package/dist/esm/core/external-providers/provider-scripts.website.js +0 -99
  263. package/dist/esm/core/external-providers/provider-scripts.website.js.map +0 -1
  264. package/src/core/external-providers/__tests__/provider-scripts.website.test.ts +0 -146
  265. package/src/core/external-providers/provider-scripts.website.ts +0 -126
@@ -0,0 +1,164 @@
1
+ import React, { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+ import { ANONYMOUS_CONSENT_RECORD_METHOD, ANONYMOUS_CONSENT_RECORD_PATH } from '@wildo-ai/saas-models/public-runtime';
3
+ import type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
4
+
5
+ import {
6
+ readWebsiteVisitorConsent,
7
+ subscribeWebsiteVisitorConsent,
8
+ writeWebsiteVisitorConsent,
9
+ type WebsiteVisitorConsentRecord,
10
+ } from './website-consent-storage';
11
+
12
+ /**
13
+ * @wildo_source:part:start saas.website.consent.context facet:layer:core facet:family:website
14
+ *
15
+ * `WebsiteConsentContext` — a website visitor's consent state (#534), provided once per page by
16
+ * `WebsitePageLayout`.
17
+ *
18
+ * Consent is a SET of `ConsentPurpose`s, each granted or not on its own. Nothing optional runs until its
19
+ * purpose is granted: the layout's provider SDKs and scripts read `grantedPurposes`, and so does
20
+ * `<WebsiteConsentGate purpose={…}>`.
21
+ *
22
+ * `purposesInUse` is DERIVED from the live providers (`consentPurposesInUseByProviders`), so the panel asks
23
+ * about exactly the purposes this site processes and nothing else. A site running no consent-based
24
+ * provider shows no panel at all.
25
+ */
26
+ export enum WebsiteConsentStatus {
27
+ /** The visitor has not decided — or their decision cannot be read. Nothing optional runs. */
28
+ UNDECIDED = 'undecided',
29
+ /** The visitor decided; `grantedPurposes` is their answer, possibly empty. */
30
+ DECIDED = 'decided',
31
+ }
32
+
33
+ export interface WebsiteConsentContextValue {
34
+ status: WebsiteConsentStatus;
35
+ /**
36
+ * `false` during the server render and the first client render, before storage has been read. A
37
+ * consent panel must not render while this is false: it would flash for a visitor who already decided,
38
+ * and differ from the static HTML. Optional behaviour stays off either way.
39
+ */
40
+ isResolved: boolean;
41
+ /** What the visitor granted. Empty while UNDECIDED. */
42
+ grantedPurposes: readonly ConsentPurpose[];
43
+ /** When the visitor decided, ISO-8601; absent while UNDECIDED. */
44
+ decidedAt?: string;
45
+ /** The purposes this site's live providers rest on — what a consent panel offers. */
46
+ purposesInUse: readonly ConsentPurpose[];
47
+ /** Whether the visitor re-opened the panel to change a decision already made. */
48
+ isSettingsOpen: boolean;
49
+ /** Whether `purpose` is granted. */
50
+ hasGranted: (purpose: ConsentPurpose) => boolean;
51
+ /** Record the WHOLE set the visitor grants — a purpose left out is not granted. Closes the panel. */
52
+ decide: (grantedPurposes: readonly ConsentPurpose[]) => void;
53
+ /** Re-open the panel so the visitor can change their decision. */
54
+ openSettings: () => void;
55
+ /** Close a re-opened panel without changing anything. */
56
+ closeSettings: () => void;
57
+ }
58
+
59
+ export const WebsiteConsentContext: React.Context<WebsiteConsentContextValue | null> = createContext<WebsiteConsentContextValue | null>(null);
60
+
61
+ /**
62
+ * The narrow slice of `WebsiteAnonymousSessionClient` this provider needs, declared structurally
63
+ * rather than imported (#1258).
64
+ *
65
+ * Structural because the provider does not want the client's construction concerns — an `apiOrigin`
66
+ * and the application's own `frontendServiceName`, which only the application knows — and because it
67
+ * makes the durable write trivially substitutable in a test.
68
+ */
69
+ export interface WebsiteConsentRecorder {
70
+ /**
71
+ * NOT generic, deliberately, though `WebsiteAnonymousSessionClient.requestAnonymous` is: this
72
+ * provider ignores the response entirely, so a type parameter here would be a promise to the caller
73
+ * that nothing keeps. The client's generic method satisfies this shape structurally.
74
+ *
75
+ * It takes the METHOD rather than being named after one. The consent door is a PUT — its operation
76
+ * borrows `UPDATE_MANY` — and this interface previously named `postAnonymous`, so every decision a
77
+ * visitor made 404'd, silently, by this provider's own (correct) design of never telling a visitor
78
+ * their privacy choice failed. A door's method is part of its address, so it is passed like one.
79
+ */
80
+ requestAnonymous(method: string, path: string, body: unknown): Promise<unknown>;
81
+ }
82
+
83
+ export interface WebsiteConsentProviderProps {
84
+ purposesInUse: readonly ConsentPurpose[];
85
+ /**
86
+ * OPTIONAL. When present, a decision is ALSO recorded server-side against the visitor's anonymous
87
+ * session, which is what lets it follow them into the account they go on to create (#1258).
88
+ *
89
+ * Absent is a first-class answer and is exactly today's behaviour: the decision lives in this
90
+ * browser and nowhere else. A site with no anonymous-session wiring keeps working, gates the same
91
+ * way, and simply carries nothing forward.
92
+ */
93
+ recorder?: WebsiteConsentRecorder;
94
+ children: ReactNode;
95
+ }
96
+
97
+ export function WebsiteConsentProvider({ purposesInUse, recorder, children }: WebsiteConsentProviderProps): ReactNode {
98
+ const [record, setRecord] = useState<WebsiteVisitorConsentRecord | null>(null);
99
+ const [isResolved, setIsResolved] = useState(false);
100
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
101
+
102
+ // Read after mount, never during render: the static HTML was produced with no storage at all.
103
+ useEffect(() => {
104
+ setRecord(readWebsiteVisitorConsent());
105
+ setIsResolved(true);
106
+ return subscribeWebsiteVisitorConsent(() => setRecord(readWebsiteVisitorConsent()));
107
+ }, []);
108
+
109
+ const decide = useCallback((grantedPurposes: readonly ConsentPurpose[]): void => {
110
+ /*
111
+ * Local storage FIRST, and it is what gates this page (D6). The site is statically built, so a
112
+ * refusal that only held while an API answered would not hold at all — and the visitor's panel
113
+ * must close on their click, not on a round trip.
114
+ */
115
+ setRecord(writeWebsiteVisitorConsent(grantedPurposes));
116
+ setIsSettingsOpen(false);
117
+
118
+ /*
119
+ * Then the DURABLE copy, best-effort. This is the one that survives the browser and is carried
120
+ * onto the account at signup; its failure changes nothing the visitor can see, so it is never
121
+ * awaited and never surfaced.
122
+ *
123
+ * `purposesInUse` is sent as the OFFERED set — what this panel actually put in front of them.
124
+ * Without it the application could not tell a declined purpose from one this site never asked
125
+ * about, and would record a decision the visitor never made
126
+ * (`AnonymousUserPrivacySchema.offeredPurposes`).
127
+ */
128
+ if (recorder !== undefined && purposesInUse.length > 0) {
129
+ void recorder
130
+ .requestAnonymous(ANONYMOUS_CONSENT_RECORD_METHOD, ANONYMOUS_CONSENT_RECORD_PATH, {
131
+ grantedPurposes: [...grantedPurposes],
132
+ offeredPurposes: [...purposesInUse],
133
+ })
134
+ .catch(() => {
135
+ /*
136
+ * Deliberately silent. The decision HELD — it is in this browser and gating this page. All
137
+ * that is lost is the carry, and telling a visitor their privacy choice failed when it did
138
+ * not would be worse than losing it.
139
+ */
140
+ });
141
+ }
142
+ }, [recorder, purposesInUse]);
143
+ const openSettings = useCallback((): void => setIsSettingsOpen(true), []);
144
+ const closeSettings = useCallback((): void => setIsSettingsOpen(false), []);
145
+
146
+ const value = useMemo<WebsiteConsentContextValue>(() => {
147
+ const grantedPurposes = record?.grantedPurposes ?? [];
148
+ return {
149
+ status: record === null ? WebsiteConsentStatus.UNDECIDED : WebsiteConsentStatus.DECIDED,
150
+ isResolved,
151
+ grantedPurposes,
152
+ ...(record !== null ? { decidedAt: record.decidedAt } : {}),
153
+ purposesInUse,
154
+ isSettingsOpen,
155
+ hasGranted: (purpose) => grantedPurposes.includes(purpose),
156
+ decide,
157
+ openSettings,
158
+ closeSettings,
159
+ };
160
+ }, [record, isResolved, purposesInUse, isSettingsOpen, decide, openSettings, closeSettings]);
161
+
162
+ return <WebsiteConsentContext.Provider value={value}>{children}</WebsiteConsentContext.Provider>;
163
+ }
164
+ /** @wildo_source:part:end saas.website.consent.context */
@@ -0,0 +1,34 @@
1
+ import React, { type ReactNode } from 'react';
2
+ import type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
3
+
4
+ import { useWebsiteConsent } from './useWebsiteConsent';
5
+
6
+ /**
7
+ * @wildo_source:part:start saas.website.consent.gate facet:layer:core facet:family:website
8
+ *
9
+ * `<WebsiteConsentGate>` — renders its children only when the visitor's consent allows it (#534).
10
+ *
11
+ * - **No `purpose`** — the content is ESSENTIAL and always renders. This is the right wrapper for a
12
+ * contact form: submitting a form the visitor chose to fill in never depends on optional consent, and the
13
+ * form's own opt-ins (marketing) are recorded with the submission, not here.
14
+ * - **`purpose`** — the content rests on that purpose (an embedded video that tracks, a chat widget, a
15
+ * marketing pixel). It renders only once the visitor granted it; until then `fallback` renders — a
16
+ * placeholder that says what is withheld and offers `useWebsiteConsent().openSettings()`.
17
+ *
18
+ * It renders `fallback` during the server render and before the stored decision is read, so a static page
19
+ * never ships consent-based content in its HTML.
20
+ */
21
+ export interface WebsiteConsentGateProps {
22
+ /** The purpose the children rest on. Omit for essential content. */
23
+ purpose?: ConsentPurpose;
24
+ /** Rendered in place of consent-based children until the purpose is granted. Defaults to nothing. */
25
+ fallback?: ReactNode;
26
+ children: ReactNode;
27
+ }
28
+
29
+ export function WebsiteConsentGate({ purpose, fallback = null, children }: WebsiteConsentGateProps): ReactNode {
30
+ const consent = useWebsiteConsent();
31
+ if (purpose === undefined) return <>{children}</>;
32
+ return <>{consent.isResolved && consent.hasGranted(purpose) ? children : fallback}</>;
33
+ }
34
+ /** @wildo_source:part:end saas.website.consent.gate */
@@ -0,0 +1,18 @@
1
+ import { useContext } from 'react';
2
+
3
+ import { WebsiteConsentContext, type WebsiteConsentContextValue } from './WebsiteConsentContext';
4
+
5
+ /**
6
+ * The visitor's consent state (#534). Read this — never storage, never a cookie — to decide whether
7
+ * optional behaviour may run.
8
+ *
9
+ * Throws outside `WebsitePageLayout`, which provides it: a consumer that silently read "nothing granted"
10
+ * would look correct and hide a page mounted outside the layout.
11
+ */
12
+ export function useWebsiteConsent(): WebsiteConsentContextValue {
13
+ const value = useContext(WebsiteConsentContext);
14
+ if (value === null) {
15
+ throw new Error('useWebsiteConsent must be used inside <WebsitePageLayout>, which provides the visitor consent state.');
16
+ }
17
+ return value;
18
+ }
@@ -0,0 +1,116 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ ConsentPurpose,
4
+ normalizeConsentPurposes,
5
+ WEBSITE_VISITOR_CONSENT_STORAGE_KEY,
6
+ } from '@wildo-ai/saas-models/public-runtime';
7
+
8
+ /**
9
+ * The ONE place a Wildo website reads or writes a visitor's consent decision (#534).
10
+ *
11
+ * Page and section components never touch storage for consent: they read `useWebsiteConsent()`. A second
12
+ * reader would be a second interpretation of the record — the defect the old `CONSENT_ORDER` was, one
13
+ * layer down.
14
+ *
15
+ * ## Why web storage and not a cookie
16
+ *
17
+ * Nothing on the server reads a visitor's consent — the marketing site is static — so a cookie would be
18
+ * sent on every request for no reader. Web storage stays in the browser. The key is declared in the
19
+ * engine's cookie table (`ENGINE_CLIENT_STORAGE_ENTRIES`) as strictly necessary: a refusal that is not
20
+ * remembered does not hold.
21
+ *
22
+ * ## Fail closed, never loud
23
+ *
24
+ * Storage can be absent (a server render), disabled (a privacy mode) or corrupt (a hand edit). Every one
25
+ * of those reads as "no decision": the visitor is asked, and nothing optional starts. A corrupt record is
26
+ * not repaired silently — it is ignored, and the next decision overwrites it.
27
+ */
28
+
29
+ /** Stored shape. `schemaVersion` so a future shape change can refuse an old record instead of misreading it. */
30
+ const StoredWebsiteVisitorConsentSchema = z.object({
31
+ schemaVersion: z.literal(1),
32
+ /** Strings, not the enum: a purpose later removed from the vocabulary must not invalidate the rest. */
33
+ grantedPurposes: z.array(z.string()),
34
+ decidedAt: z.iso.datetime(),
35
+ });
36
+
37
+ /** A visitor's decision, as the website runtime uses it. */
38
+ export interface WebsiteVisitorConsentRecord {
39
+ readonly grantedPurposes: readonly ConsentPurpose[];
40
+ /** When the visitor decided, ISO-8601. Its presence is what separates "declined everything" from "never asked". */
41
+ readonly decidedAt: string;
42
+ }
43
+
44
+ /**
45
+ * Dispatched on `window` after this document writes a decision, so every island on the page updates. The
46
+ * browser's own `storage` event covers OTHER documents only.
47
+ */
48
+ export const WEBSITE_CONSENT_CHANGED_EVENT = 'wildo:website-consent-changed';
49
+
50
+ function browserStorage(): Storage | null {
51
+ try {
52
+ return typeof window === 'undefined' ? null : window.localStorage;
53
+ } catch {
54
+ // Accessing `localStorage` throws when the browser blocks site data.
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** The stored decision, or `null` when there is none or it cannot be read. */
60
+ export function readWebsiteVisitorConsent(storage: Storage | null = browserStorage()): WebsiteVisitorConsentRecord | null {
61
+ if (storage === null) return null;
62
+ let raw: string | null;
63
+ try {
64
+ raw = storage.getItem(WEBSITE_VISITOR_CONSENT_STORAGE_KEY);
65
+ } catch {
66
+ return null;
67
+ }
68
+ if (raw === null) return null;
69
+ let json: unknown;
70
+ try {
71
+ json = JSON.parse(raw);
72
+ } catch {
73
+ return null;
74
+ }
75
+ const parsed = StoredWebsiteVisitorConsentSchema.safeParse(json);
76
+ if (!parsed.success) return null;
77
+ return { grantedPurposes: normalizeConsentPurposes(parsed.data.grantedPurposes), decidedAt: parsed.data.decidedAt };
78
+ }
79
+
80
+ /**
81
+ * Store a decision and tell the page. Returns whether it was stored: when storage is unavailable the
82
+ * decision still applies for this page, and the visitor is asked again on the next one.
83
+ */
84
+ export function writeWebsiteVisitorConsent(
85
+ grantedPurposes: readonly ConsentPurpose[],
86
+ now: Date = new Date(),
87
+ storage: Storage | null = browserStorage(),
88
+ ): WebsiteVisitorConsentRecord {
89
+ const record: WebsiteVisitorConsentRecord = { grantedPurposes: normalizeConsentPurposes(grantedPurposes), decidedAt: now.toISOString() };
90
+ if (storage !== null) {
91
+ try {
92
+ storage.setItem(
93
+ WEBSITE_VISITOR_CONSENT_STORAGE_KEY,
94
+ JSON.stringify({ schemaVersion: 1, grantedPurposes: record.grantedPurposes, decidedAt: record.decidedAt }),
95
+ );
96
+ } catch {
97
+ // Quota or blocked storage: the decision holds for this page only.
98
+ }
99
+ }
100
+ if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent(WEBSITE_CONSENT_CHANGED_EVENT));
101
+ return record;
102
+ }
103
+
104
+ /** Calls `listener` whenever a decision changes in this document or in another tab. Returns the unsubscribe. */
105
+ export function subscribeWebsiteVisitorConsent(listener: () => void): () => void {
106
+ if (typeof window === 'undefined') return () => {};
107
+ const onStorage = (event: StorageEvent): void => {
108
+ if (event.key === null || event.key === WEBSITE_VISITOR_CONSENT_STORAGE_KEY) listener();
109
+ };
110
+ window.addEventListener(WEBSITE_CONSENT_CHANGED_EVENT, listener);
111
+ window.addEventListener('storage', onStorage);
112
+ return () => {
113
+ window.removeEventListener(WEBSITE_CONSENT_CHANGED_EVENT, listener);
114
+ window.removeEventListener('storage', onStorage);
115
+ };
116
+ }
@@ -1,9 +1,10 @@
1
- import React, { createContext, type ReactNode } from 'react';
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,21 @@ 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;
164
+ /**
165
+ * Replaces the framework's visitor consent panel (#534). Reads `useWebsiteConsent()`; see
166
+ * `WebsiteConsentBanner` for the three properties a replacement must keep. Absent uses the default.
167
+ * Closed over by the bridge rather than passed as an island prop, like `navigation`, because a
168
+ * component cannot cross Astro's serialization boundary.
169
+ */
170
+ consentBannerComponent?: ComponentType;
155
171
  /**
156
172
  * Backend API origin (WITHOUT a trailing slash) that anonymous-capture
157
173
  * sections POST to when constructing a `WebsiteAnonymousSessionClient`
@@ -166,6 +182,17 @@ export interface WebsiteRuntimeContextValue {
166
182
  * silently failing to submit.
167
183
  */
168
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;
169
196
  }
170
197
 
171
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 */
@@ -0,0 +1,153 @@
1
+ import React, { type ReactNode } from 'react';
2
+ import { ProviderComponentStatus } from '@wildo-ai/external-connectors-public/website';
3
+ import type { ConsentPurpose, ProviderCapability } from '@wildo-ai/saas-models/public-runtime';
4
+
5
+ import { websiteDesignTokenCssVar } from '../../schemas/design-tokens/website-design-tokens.shared.schemas';
6
+ import {
7
+ WebsiteConsentLabelSlot,
8
+ websiteConsentLabelKey,
9
+ websiteConsentPurposeLabelKey,
10
+ } from '../../schemas/label-keys/website-consent-label-keys.schemas';
11
+ import {
12
+ WebsiteProviderComponentLabelSlot,
13
+ websiteProviderComponentLabelKey,
14
+ } from '../../schemas/label-keys/website-provider-component-label-keys.schemas';
15
+ import { useWebsiteConsent } from '../consent/useWebsiteConsent';
16
+ import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';
17
+ import { useWebsiteProviderComponent } from './useWebsiteProviderComponent';
18
+
19
+ const STATE_STYLE: React.CSSProperties = {
20
+ display: 'flex',
21
+ flexDirection: 'column',
22
+ gap: '0.5rem',
23
+ padding: '0.75rem',
24
+ border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
25
+ borderRadius: websiteDesignTokenCssVar('radii', 'md', '0.375rem'),
26
+ };
27
+
28
+ const BUTTON_STYLE: React.CSSProperties = {
29
+ alignSelf: 'flex-start',
30
+ padding: '0.5rem 1rem',
31
+ borderRadius: websiteDesignTokenCssVar('radii', 'md', '0.375rem'),
32
+ border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
33
+ background: websiteDesignTokenCssVar('colors', 'background', '#fff'),
34
+ color: websiteDesignTokenCssVar('colors', 'foreground', '#000'),
35
+ cursor: 'pointer',
36
+ };
37
+
38
+ function PurposeName({ purpose }: { purpose: ConsentPurpose }): ReactNode {
39
+ return <li data-website-provider-component-purpose={purpose}>{useWebsiteLabelByKey(websiteConsentPurposeLabelKey(purpose))}</li>;
40
+ }
41
+
42
+ /**
43
+ * The words announced for a refusal. Rendered INTO the always-present live region, so a screen reader
44
+ * announces the change when it happens; a region inserted already filled is often not announced at all.
45
+ */
46
+ function RefusalStatement({ status, missingPurposes }: { status: ProviderComponentStatus; missingPurposes: readonly ConsentPurpose[] }): ReactNode {
47
+ const consentRequired = useWebsiteLabelByKey(websiteProviderComponentLabelKey(WebsiteProviderComponentLabelSlot.CONSENT_REQUIRED));
48
+ const unavailable = useWebsiteLabelByKey(websiteProviderComponentLabelKey(WebsiteProviderComponentLabelSlot.UNAVAILABLE));
49
+ if (status === ProviderComponentStatus.UNAVAILABLE) return <p style={{ margin: 0 }}>{unavailable}</p>;
50
+ return (
51
+ <>
52
+ <p style={{ margin: 0 }}>{consentRequired}</p>
53
+ <ul style={{ margin: 0 }}>
54
+ {missingPurposes.map((purpose) => <PurposeName key={purpose} purpose={purpose} />)}
55
+ </ul>
56
+ </>
57
+ );
58
+ }
59
+
60
+ function ManageConsentButton({ onClick }: { onClick: () => void }): ReactNode {
61
+ return <button type="button" style={BUTTON_STYLE} onClick={onClick}>{useWebsiteLabelByKey(websiteConsentLabelKey(WebsiteConsentLabelSlot.MANAGE))}</button>;
62
+ }
63
+
64
+ /**
65
+ * @wildo_source:part:start saas.website.provider-component.slot facet:layer:core facet:family:website
66
+ *
67
+ * `<WebsiteProviderComponent>` — places ONE provider's component on a website page (#1703): an
68
+ * address lookup on a contact form, a vendor widget on a marketing page. The component comes from the
69
+ * provider's own `components[capability]` declaration, the same one the application reads, through
70
+ * the website's provider-component registry.
71
+ *
72
+ * What it guarantees, so a page does not have to:
73
+ *
74
+ * - **Nothing of the vendor reaches the static HTML.** The component loads from an effect, which never
75
+ * runs during the Astro server render, and the registry itself refuses to load where there is no
76
+ * browser document. The server renders only the reserved box.
77
+ * - **No layout shift.** `reservedMinHeight` applies to the box in EVERY state — the server
78
+ * placeholder, loading, the refusals and the loaded component — so the page does not jump when the
79
+ * component arrives. Set it to the component's rendered height.
80
+ * - **The vendor's scripts load with the component**, not on every page: the layout injects at startup
81
+ * only the scripts of providers that contribute no component.
82
+ * - **Consent before download.** A provider resting on a purpose the visitor has not granted downloads
83
+ * nothing; the box states that the feature is off, names the purposes it rests on, and offers the
84
+ * consent panel (`openSettings`). Granting there loads the component in place.
85
+ * - **Announced, and operable by keyboard.** The box holds a `role="status"` live region from the first
86
+ * render, so a refusal is announced when it appears; the consent button sits outside it, so it is not
87
+ * read out as part of the message, and is a real button.
88
+ * - **A failure says so.** A component that could not load renders the site's `unavailable` wording,
89
+ * never a blank. A provider that offers no component here (left off in this environment) renders the
90
+ * empty box, since there is nothing to retry.
91
+ *
92
+ * The words come from the site's label pack: `website.chrome.provider-component.consent-required` and
93
+ * `.unavailable` are framework-owned keys every pack must carry, and the purpose names and the button
94
+ * reuse the consent panel's keys. Pass `renderConsentRequired` to word the refusal in the page's own
95
+ * chrome instead; it receives the missing purposes and the action that opens the panel, and then owns
96
+ * its own announcement.
97
+ *
98
+ * It does not choose a provider. Pass the ref the page chose — `useWebsiteProviderComponentChoices`
99
+ * lists the candidates — or `undefined` while nothing is chosen.
100
+ */
101
+ export interface WebsiteProviderComponentProps<TProps extends object> {
102
+ /** The capability the component fills, e.g. `FRONTEND_ADDRESS_LOOKUP`. */
103
+ capability: ProviderCapability;
104
+ /** The provider the page chose, or `undefined` while nothing is chosen. */
105
+ providerRef: string | undefined;
106
+ /** The props the loaded component receives — the capability's slot contract. */
107
+ componentProps: TProps;
108
+ /** CSS length reserved for the box in every state, so the page does not shift when the component loads. */
109
+ reservedMinHeight?: string;
110
+ /** Replaces the default consent-required state. */
111
+ renderConsentRequired?: (input: { missingPurposes: readonly ConsentPurpose[]; openConsentSettings: () => void }) => ReactNode;
112
+ }
113
+
114
+ export function WebsiteProviderComponent<TProps extends object>({
115
+ capability,
116
+ providerRef,
117
+ componentProps,
118
+ reservedMinHeight,
119
+ renderConsentRequired,
120
+ }: WebsiteProviderComponentProps<TProps>): ReactNode {
121
+ const consent = useWebsiteConsent();
122
+ const { status, Component, missingConsentPurposes } = useWebsiteProviderComponent(capability, providerRef);
123
+
124
+ const isReady = status === ProviderComponentStatus.READY && Component !== null;
125
+ const isConsentRequired = status === ProviderComponentStatus.CONSENT_REQUIRED;
126
+ const isUnavailable = status === ProviderComponentStatus.UNAVAILABLE;
127
+ const useDefaultRefusal = isUnavailable || (isConsentRequired && renderConsentRequired === undefined);
128
+ // The registry cannot know a capability's props; the page states them through `componentProps`.
129
+ const Loaded = isReady ? (Component as unknown as React.ComponentType<TProps>) : null;
130
+
131
+ return (
132
+ <div
133
+ data-website-provider-component={capability}
134
+ data-website-provider-component-state={status}
135
+ aria-busy={status === ProviderComponentStatus.LOADING ? true : undefined}
136
+ style={reservedMinHeight !== undefined ? { minHeight: reservedMinHeight } : undefined}
137
+ >
138
+ {Loaded !== null ? <Loaded {...componentProps} /> : null}
139
+ {isConsentRequired && renderConsentRequired !== undefined
140
+ ? renderConsentRequired({ missingPurposes: missingConsentPurposes, openConsentSettings: consent.openSettings })
141
+ : null}
142
+ {Loaded === null ? (
143
+ <div style={useDefaultRefusal ? STATE_STYLE : undefined}>
144
+ <div role="status">
145
+ {useDefaultRefusal ? <RefusalStatement status={status} missingPurposes={missingConsentPurposes} /> : null}
146
+ </div>
147
+ {isConsentRequired && renderConsentRequired === undefined ? <ManageConsentButton onClick={consent.openSettings} /> : null}
148
+ </div>
149
+ ) : null}
150
+ </div>
151
+ );
152
+ }
153
+ /** @wildo_source:part:end saas.website.provider-component.slot */
@@ -10,7 +10,9 @@ import { describe, expect, it, vi } from 'vitest';
10
10
  import {
11
11
  ENGINE_PROVIDER_ORIGIN,
12
12
  ProviderTier,
13
+ defineFrontendPublicConfigSchema,
13
14
  defineProviderMetadata,
15
+ FrontendPublicConfigValueKind,
14
16
  type FrontendProvidersBlock,
15
17
  } from '@wildo-ai/saas-models/public-runtime';
16
18
  import type { FrontendWebsiteProviderRef } from '@wildo-ai/external-connectors-public/website';
@@ -51,6 +53,8 @@ describe('createFrontendWebsiteProviderRegistry', () => {
51
53
  providerCapabilities: ['FRONTEND_CAPTCHA'],
52
54
  protocols: ['FRONTEND_SDK'],
53
55
  sdk: { descriptor: { packageName: '@acme/analytics-web' }, activate },
56
+ // A readable key list is mandatory since #1705, or hydration withholds the code.
57
+ publicConfigSchema: defineFrontendPublicConfigSchema({ token: { kind: FrontendPublicConfigValueKind.STRING } }),
54
58
  },
55
59
  },
56
60
  });