@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,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
+ });
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
+ import { ConsentPurpose } from "@wildo-ai/saas-models/public-runtime";
4
+
3
5
  import { InboundContactFormSchema, buildInboundContactCreateBody, type InboundContactFormValues } from "../inbound-contact-form.schema";
4
6
 
5
7
  /**
@@ -36,9 +38,14 @@ describe("buildInboundContactCreateBody — no synthetic legal receipt", () => {
36
38
  expect(buildInboundContactCreateBody(values).email).toBe("lead@example.com");
37
39
  });
38
40
 
39
- it("maps the OPTIONAL marketing opt-in to the dataProcessingConsent tier", () => {
40
- const tier = (v: InboundContactFormValues): string => (buildInboundContactCreateBody(v).privacy as { dataProcessingConsent: string }).dataProcessingConsent;
41
- expect(tier({ ...values, marketingConsent: true })).toBe("marketing");
42
- expect(tier({ ...values, marketingConsent: false })).toBe("basic");
41
+ it("maps the OPTIONAL marketing opt-in to the consented purpose SET — nothing granted when unticked", () => {
42
+ const purposes = (v: InboundContactFormValues): unknown => (buildInboundContactCreateBody(v).privacy as { consentedPurposes: unknown }).consentedPurposes;
43
+ expect(purposes({ ...values, marketingConsent: true })).toEqual([ConsentPurpose.MARKETING]);
44
+ expect(purposes({ ...values, marketingConsent: false })).toEqual([]);
45
+ });
46
+
47
+ it("records that the visitor decided, either way — the server re-stamps the instant", () => {
48
+ const privacy = buildInboundContactCreateBody(values).privacy as { consentDecidedAt?: string };
49
+ expect(typeof privacy.consentDecidedAt).toBe("string");
43
50
  });
44
51
  });
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * Mirrors the non-excluded, non-backend-only CREATE inputs of `InboundContact`
13
13
  * (`email` required; `firstName` / `lastName` / `phone` optional), plus ONE privacy
14
- * signal: an OPTIONAL marketing opt-in (mapped to the `privacy.dataProcessingConsent`
15
- * tier). There is no second, required agreement field: a governed notice, contract or
14
+ * signal: an OPTIONAL marketing opt-in (mapped to the `privacy.consentedPurposes` set).
15
+ * There is no second, required agreement field: a governed notice, contract or
16
16
  * consent interaction must use the separate exact-release receipt contract, because this
17
17
  * DTO cannot manufacture legal evidence from a checkbox and a client timestamp.
18
18
  * `relatedUserId` is intentionally absent — it is `.excludeFromCreate()` on the
@@ -21,22 +21,37 @@
21
21
 
22
22
  import { z } from "zod";
23
23
 
24
+ import { ConsentPurpose } from "@wildo-ai/saas-models/public-runtime";
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
+
24
34
  /**
25
35
  * The wire payload POSTed to `/api/v1/inbound-contacts`. `privacy` matches the
26
36
  * resource's `InboundContactPrivacySchema` shape (a nested consent object) so the
27
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.
28
42
  */
29
43
  export const InboundContactFormSchema = z.object({
30
44
  // Zod 4: top-level `z.email()` (not the deprecated `z.string().email()`), matching
31
45
  // the resource schema. An empty string fails it, so it also covers "required".
32
- email: z.email("Enter a valid email address"),
33
- firstName: z.string().trim().max(200).optional(),
34
- lastName: z.string().trim().max(200).optional(),
35
- 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(),
36
50
  /**
37
- * OPTIONAL marketing opt-in — maps into the resource's `privacy.dataProcessingConsent`
38
- * tier ('marketing' vs the BASIC default). It is the only privacy field on this form,
39
- * and it gates nothing: an unchecked box still submits, at the BASIC tier.
51
+ * OPTIONAL marketing opt-in — maps into the resource's `privacy.consentedPurposes` set.
52
+ * It is the only privacy field on this form, and it gates nothing: sending the form is the
53
+ * essential act and never depends on optional consent, so an unchecked box still submits,
54
+ * granting nothing.
40
55
  */
41
56
  marketingConsent: z.boolean().default(false),
42
57
  });
@@ -45,7 +60,7 @@ export type InboundContactFormValues = z.infer<typeof InboundContactFormSchema>;
45
60
 
46
61
  /**
47
62
  * Build the exact JSON body the backend CREATE expects from validated form values.
48
- * Keeps the `privacy` nesting + the consent enum mapping in ONE place so the form
63
+ * Keeps the `privacy` nesting + the consent purpose mapping in ONE place so the form
49
64
  * component stays declarative.
50
65
  */
51
66
  export function buildInboundContactCreateBody(values: InboundContactFormValues): Record<string, unknown> {
@@ -55,9 +70,11 @@ export function buildInboundContactCreateBody(values: InboundContactFormValues):
55
70
  ...(values.lastName ? { lastName: values.lastName } : {}),
56
71
  ...(values.phone ? { phone: values.phone } : {}),
57
72
  privacy: {
58
- // OPTIONAL marketing tier: 'marketing' when the visitor opted in, else the
59
- // resource's BASIC default.
60
- dataProcessingConsent: values.marketingConsent ? "marketing" : "basic",
73
+ // The visitor SAW the opt-in and submitted, so this is a decision either way (#534): the set
74
+ // they granted — MARKETING or nothing — plus a CLAIM that they decided now. The backend's
75
+ // CREATE prefix replaces the instant with its own clock; a browser clock is never recorded.
76
+ consentedPurposes: values.marketingConsent ? [ConsentPurpose.MARKETING] : [],
77
+ consentDecidedAt: new Date().toISOString(),
61
78
  },
62
79
  };
63
80
  }
@@ -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,12 +121,48 @@ 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> {
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;
142
+ }
143
+
144
+ /**
145
+ * Send `body` to an anonymous endpoint with an explicit METHOD.
146
+ *
147
+ * `postAnonymous` above is the POST case and delegates here. This exists because not every anonymous
148
+ * door is a POST and the method is not guessable from the path: the visitor consent door declares
149
+ * `resourceOperationLike: UPDATE_MANY`, which the engine maps to PUT, so a POST to it answers 404 —
150
+ * and this client's one silent caller swallowed exactly that. A caller reads the door's method from
151
+ * the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside
152
+ * `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.
153
+ */
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> {
112
161
  this.ensureSessionStarted();
113
162
 
114
163
  const response = await fetch(`${this.apiBaseUrl}${path}`, {
115
- method: 'POST',
116
- headers: this.buildHeaders(),
164
+ method,
165
+ headers: { ...extraHeaders, ...this.buildHeaders() },
117
166
  body: JSON.stringify(body),
118
167
  // Carry the parent-domain `__wildo_anon_session` cookie to the API so the
119
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 */
@@ -0,0 +1,223 @@
1
+ import React, { useEffect, useId, useState, type ReactNode } from 'react';
2
+ import type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
3
+
4
+ import { websiteDesignTokenCssVar } from '../../schemas/design-tokens/website-design-tokens.shared.schemas';
5
+ import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';
6
+ import { WebsiteConsentStatus } from './WebsiteConsentContext';
7
+ import { useWebsiteConsent } from './useWebsiteConsent';
8
+ import {
9
+ WebsiteConsentLabelSlot,
10
+ websiteConsentLabelKey,
11
+ websiteConsentPurposeDescriptionLabelKey,
12
+ websiteConsentPurposeLabelKey,
13
+ } from '../../schemas/label-keys/website-consent-label-keys.schemas';
14
+
15
+ /**
16
+ * @wildo_source:part:start saas.website.consent.banner facet:layer:core facet:family:website
17
+ *
18
+ * `WebsiteConsentBanner` — the framework's default visitor consent panel (#534).
19
+ *
20
+ * Rendered by `WebsitePageLayout` when the site runs a consent-based provider and the visitor has not
21
+ * decided, or re-opened the panel. A site replaces it by passing `consentBannerComponent` to its bridge
22
+ * island; a replacement reads `useWebsiteConsent()` exactly as this one does and must keep its three
23
+ * properties:
24
+ *
25
+ * 1. **No box is pre-ticked.** A pre-ticked box is not a choice.
26
+ * 2. **Declining is as easy as accepting**, and is RECORDED — so the panel does not return on every page.
27
+ * 3. **Only purposes in use are offered** (`purposesInUse`), each named by its label.
28
+ *
29
+ * Styled only through the site's `--website-*` design tokens, like the skip link, so it needs no
30
+ * stylesheet and cannot be shadowed by a utility class.
31
+ */
32
+ function useWebsiteConsentSlotLabel(slot: WebsiteConsentLabelSlot): string {
33
+ return useWebsiteLabelByKey(websiteConsentLabelKey(slot));
34
+ }
35
+
36
+ /**
37
+ * Every row has the SAME shape: the control, then the name on its line, then the explanation beneath
38
+ * the NAME rather than beneath the control — a two-column grid, so the second line starts where the
39
+ * first line's words do and the row stays scannable.
40
+ *
41
+ * That includes the row that cannot be changed, which carries a real checkbox, CHECKED and DISABLED,
42
+ * rather than a text status. A person scanning the panel reads DOWN the column of controls, and a row
43
+ * whose control is missing does not read as "cannot be changed" — it reads as a row that was
44
+ * forgotten. A checked, disabled box says it in the vocabulary the rest of the list already uses, and
45
+ * a screen reader announces "checked, unavailable" without any extra words.
46
+ */
47
+ const ROW_STYLE: React.CSSProperties = {
48
+ display: 'grid',
49
+ gridTemplateColumns: 'auto 1fr',
50
+ columnGap: '0.625rem',
51
+ alignItems: 'center',
52
+ padding: '0.5rem 0.75rem',
53
+ borderBottom: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
54
+ };
55
+
56
+ const ROW_DESCRIPTION_STYLE: React.CSSProperties = { margin: 0, gridColumn: 2, fontSize: '0.75rem', opacity: 0.8 };
57
+
58
+ /** Slightly larger than a default box: this is the one control the whole panel exists to offer. */
59
+ const ROW_CONTROL_STYLE: React.CSSProperties = { width: '1.15rem', height: '1.15rem' };
60
+
61
+ /**
62
+ * One purpose: its NAME, what agreeing to it MEANS for this visitor, and the control.
63
+ *
64
+ * The explanation is the half that was missing. A tick box labelled "Analytics" is a category, not
65
+ * something a visitor can weigh, and consent is only valid if it is informed — which is why every
66
+ * specialized consent platform pairs the two and why the label key is derived from the vocabulary,
67
+ * so a purpose can never arrive with a name and no explanation.
68
+ */
69
+ function PurposeChoice({ purpose, checked, onChange }: { purpose: ConsentPurpose; checked: boolean; onChange: (checked: boolean) => void }): ReactNode {
70
+ const label = useWebsiteLabelByKey(websiteConsentPurposeLabelKey(purpose));
71
+ const description = useWebsiteLabelByKey(websiteConsentPurposeDescriptionLabelKey(purpose));
72
+ return (
73
+ <label data-website-consent-purpose={purpose} style={{ ...ROW_STYLE, cursor: 'pointer' }}>
74
+ <input type="checkbox" checked={checked} style={ROW_CONTROL_STYLE} onChange={(event) => onChange(event.target.checked)} />
75
+ <span style={{ fontWeight: 600 }}>{label}</span>
76
+ <span style={ROW_DESCRIPTION_STYLE}>{description}</span>
77
+ </label>
78
+ );
79
+ }
80
+
81
+ const BUTTON_STYLE: React.CSSProperties = {
82
+ padding: '0.5rem 1rem',
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'),
87
+ cursor: 'pointer',
88
+ };
89
+
90
+ export function WebsiteConsentBanner(): ReactNode {
91
+ const consent = useWebsiteConsent();
92
+ const headingId = useId();
93
+ const [selected, setSelected] = useState<readonly ConsentPurpose[]>(consent.grantedPurposes);
94
+ // Re-seed from the stored decision each time the panel is re-opened, so it shows what is in force.
95
+ useEffect(() => setSelected(consent.grantedPurposes), [consent.grantedPurposes, consent.isSettingsOpen]);
96
+
97
+ const title = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.TITLE);
98
+ const description = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.DESCRIPTION);
99
+ const acceptAll = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ACCEPT_ALL);
100
+ const declineAll = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.DECLINE_ALL);
101
+ const save = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.SAVE);
102
+ const essentialTitle = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ESSENTIAL_TITLE);
103
+ const essentialDescription = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ESSENTIAL_DESCRIPTION);
104
+
105
+ const offered = consent.purposesInUse;
106
+ // Purposes the panel does not offer keep their stored grant; offered ones are granted exactly when ticked.
107
+ const choose = (chosen: readonly ConsentPurpose[]): void =>
108
+ consent.decide([...consent.grantedPurposes.filter((purpose) => !offered.includes(purpose)), ...chosen]);
109
+
110
+ return (
111
+ <section
112
+ role="region"
113
+ aria-labelledby={headingId}
114
+ data-website-consent-banner={consent.status === WebsiteConsentStatus.UNDECIDED ? 'undecided' : 'settings'}
115
+ style={{
116
+ /*
117
+ * BOTTOM-RIGHT CORNER, and the three properties together are what make that work on a phone
118
+ * too. `left` and `right` both bound the box to the viewport with a gutter; `maxWidth` caps it
119
+ * on a wide screen; `marginLeft: auto` pushes the remaining slack to the left so the box hugs
120
+ * the right edge. On a narrow screen the gutters bind before the cap does and it simply spans
121
+ * the width — a corner popup that overflowed a phone would be worse than a bar.
122
+ *
123
+ * Inline styles, not a media query: this ships into a static marketing page with no stylesheet
124
+ * of its own, which is the same constraint that keeps the rest of this file inline.
125
+ */
126
+ position: 'fixed',
127
+ left: '1rem',
128
+ right: '1rem',
129
+ bottom: '1rem',
130
+ zIndex: 9000,
131
+ maxWidth: '30rem',
132
+ marginLeft: 'auto',
133
+ /*
134
+ * The panel never exceeds the viewport, and its ACTIONS are never what gets cut off: the rows
135
+ * scroll inside `maxHeight`, the buttons sit outside that box. A panel that grows with its
136
+ * content pushes accept and reject off the bottom of a short screen — and a refusal that
137
+ * cannot be reached is a refusal that was not offered, whatever the markup says. `dvh` rather
138
+ * than `vh` because a mobile browser's toolbars make `vh` taller than the visible page.
139
+ */
140
+ maxHeight: '85dvh',
141
+ padding: '0.875rem 1rem',
142
+ display: 'flex',
143
+ flexDirection: 'column',
144
+ gap: '0.625rem',
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'),
149
+ }}
150
+ >
151
+ <h2 id={headingId} style={{ margin: 0, fontSize: '1.125rem' }}>{title}</h2>
152
+ <p style={{ margin: 0 }}>{description}</p>
153
+ {/* `data-website-consent-rows` is the scroll box's own hook. A test anchoring on a style
154
+ substring instead matched the ROW (whose grid template also contains "auto") and asserted
155
+ nothing — the same vacuous pin the app tier already had to repair. */}
156
+ <div
157
+ data-website-consent-rows=""
158
+ style={{ minHeight: 0, flex: 1, overflowY: 'auto', border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`, borderRadius: websiteDesignTokenCssVar('radii', 'md', '0.375rem') }}
159
+ >
160
+ {/*
161
+ * The always-active row, first, carrying a CHECKED and DISABLED box. It is not a purpose the
162
+ * visitor decides — which is exactly why it is stated: a panel listing only the optional
163
+ * purposes leaves them unable to see what is happening on the site at all, which is the
164
+ * question they opened it to answer.
165
+ *
166
+ * It carried a text status instead until 2026-09-20, on the argument that a disabled control
167
+ * invites a click that cannot work. The reversal: a visitor scans DOWN the column of boxes,
168
+ * and a gap there does not read as "cannot be changed", it reads as a row that was
169
+ * forgotten. A disabled box says it in the vocabulary the rest of the list already uses.
170
+ */}
171
+ <div data-website-consent-essential="" style={ROW_STYLE}>
172
+ {/*
173
+ * `aria-label`, because this row is a `div` rather than a `<label>` — nothing else names
174
+ * the control, and a checkbox with no accessible name is announced as an unlabelled
175
+ * checkbox. Its own note claimed AT would announce "checked, unavailable"; it announced
176
+ * no words at all.
177
+ *
178
+ * `readOnly` is gone: it is inert on a checkbox per HTML, and leaving it invites a later
179
+ * reader to delete `disabled` believing the lock survives.
180
+ */}
181
+ <input type="checkbox" checked disabled aria-label={essentialTitle} style={ROW_CONTROL_STYLE} />
182
+ <span style={{ fontWeight: 600 }}>{essentialTitle}</span>
183
+ <span style={ROW_DESCRIPTION_STYLE}>{essentialDescription}</span>
184
+ </div>
185
+ {offered.map((purpose) => (
186
+ <PurposeChoice
187
+ key={purpose}
188
+ purpose={purpose}
189
+ checked={selected.includes(purpose)}
190
+ onChange={(checked) => setSelected((current) => (checked ? [...current.filter((entry) => entry !== purpose), purpose] : current.filter((entry) => entry !== purpose)))}
191
+ />
192
+ ))}
193
+ </div>
194
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', justifyContent: 'flex-end' }}>
195
+ <button type="button" style={BUTTON_STYLE} onClick={() => choose([])}>{declineAll}</button>
196
+ {/*
197
+ * SAVE appears only when a MIXED choice exists to save — two or more purposes in use.
198
+ *
199
+ * With one purpose the button is unanswerable: "decline all" and "accept all" already cover
200
+ * the whole space, so a third action beside them describes nothing a visitor can picture and
201
+ * reads as a question rather than an answer. Derived from the offered set, so a site that
202
+ * adds a second purpose gets the control with no edit here.
203
+ */}
204
+ {offered.length > 1 && (
205
+ <button type="button" style={BUTTON_STYLE} onClick={() => choose(selected.filter((purpose) => offered.includes(purpose)))}>{save}</button>
206
+ )}
207
+ <button type="button" style={BUTTON_STYLE} onClick={() => choose(offered)}>{acceptAll}</button>
208
+ </div>
209
+ </section>
210
+ );
211
+ }
212
+
213
+ /** The reopen affordance: always reachable once a visitor decided, so every choice can be changed. */
214
+ export function WebsiteConsentSettingsButton(): ReactNode {
215
+ const consent = useWebsiteConsent();
216
+ const manage = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.MANAGE);
217
+ return (
218
+ <button type="button" data-website-consent-manage="" style={BUTTON_STYLE} onClick={consent.openSettings}>
219
+ {manage}
220
+ </button>
221
+ );
222
+ }
223
+ /** @wildo_source:part:end saas.website.consent.banner */