@wildo-ai/saas-website 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/dist/esm/astro/blog-post-bridge.d.ts +9 -0
  2. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  3. package/dist/esm/astro/blog-post-bridge.js +10 -5
  4. package/dist/esm/astro/blog-post-bridge.js.map +1 -1
  5. package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -1
  6. package/dist/esm/astro/bridge-runtime.d.ts +25 -6
  7. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  8. package/dist/esm/astro/bridge-runtime.js +10 -6
  9. package/dist/esm/astro/bridge-runtime.js.map +1 -1
  10. package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -1
  11. package/dist/esm/astro/headers-renderer.d.ts.map +1 -1
  12. package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -1
  13. package/dist/esm/astro/label-pack-loader.d.ts +4 -4
  14. package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
  15. package/dist/esm/astro/label-pack-loader.js +9 -4
  16. package/dist/esm/astro/label-pack-loader.js.map +1 -1
  17. package/dist/esm/astro/llms-txt-server.d.ts.map +1 -1
  18. package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -1
  19. package/dist/esm/astro/robots-renderer.d.ts +9 -8
  20. package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
  21. package/dist/esm/astro/robots-renderer.js +15 -15
  22. package/dist/esm/astro/robots-renderer.js.map +1 -1
  23. package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -1
  24. package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -1
  25. package/dist/esm/astro/website-page-head.renderer.d.ts +1 -1
  26. package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
  27. package/dist/esm/astro/website-page-head.renderer.js +2 -1
  28. package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
  29. package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -1
  30. package/dist/esm/astro/website-site-context.d.ts +5 -2
  31. package/dist/esm/astro/website-site-context.d.ts.map +1 -1
  32. package/dist/esm/astro/website-site-context.js.map +1 -1
  33. package/dist/esm/astro-island.d.ts.map +1 -1
  34. package/dist/esm/astro.d.ts.map +1 -1
  35. package/dist/esm/companion-exports.d.ts +8 -15
  36. package/dist/esm/companion-exports.d.ts.map +1 -1
  37. package/dist/esm/companion-exports.js +8 -15
  38. package/dist/esm/companion-exports.js.map +1 -1
  39. package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -1
  40. package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -1
  41. package/dist/esm/config/define-website-config.d.ts +3 -2
  42. package/dist/esm/config/define-website-config.d.ts.map +1 -1
  43. package/dist/esm/config/define-website-config.js +10 -10
  44. package/dist/esm/config/define-website-config.js.map +1 -1
  45. package/dist/esm/config/index.d.ts.map +1 -1
  46. package/dist/esm/config/load-website-config.d.ts +6 -0
  47. package/dist/esm/config/load-website-config.d.ts.map +1 -1
  48. package/dist/esm/config/load-website-config.js +18 -9
  49. package/dist/esm/config/load-website-config.js.map +1 -1
  50. package/dist/esm/config/load-website-pricing-catalog.d.ts +13 -0
  51. package/dist/esm/config/load-website-pricing-catalog.d.ts.map +1 -0
  52. package/dist/esm/config/load-website-pricing-catalog.js +34 -0
  53. package/dist/esm/config/load-website-pricing-catalog.js.map +1 -0
  54. package/dist/esm/config/load-website-site-context.d.ts +53 -0
  55. package/dist/esm/config/load-website-site-context.d.ts.map +1 -0
  56. package/dist/esm/config/load-website-site-context.js +98 -0
  57. package/dist/esm/config/load-website-site-context.js.map +1 -0
  58. package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
  59. package/dist/esm/config-loader.d.ts +2 -0
  60. package/dist/esm/config-loader.d.ts.map +1 -1
  61. package/dist/esm/config-loader.js +2 -0
  62. package/dist/esm/config-loader.js.map +1 -1
  63. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
  64. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
  65. package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
  66. package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
  67. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +11 -0
  68. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
  69. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +15 -4
  70. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  71. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +14 -2
  72. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
  73. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +34 -4
  74. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
  75. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
  76. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
  77. package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
  78. package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
  79. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -1
  80. package/dist/esm/core/consent/WebsiteConsentBanner.js +11 -10
  81. package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -1
  82. package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -1
  83. package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -1
  84. package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -1
  85. package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -1
  86. package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
  87. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +25 -5
  88. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  89. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  90. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
  91. package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
  92. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
  93. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
  94. package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
  95. package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
  96. package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
  97. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
  98. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
  99. package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
  100. package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
  101. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  102. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
  103. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
  104. package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
  105. package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
  106. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
  107. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
  108. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
  109. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
  110. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +15 -5
  111. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
  112. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +39 -24
  113. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
  114. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
  115. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
  116. package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
  117. package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
  118. package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
  119. package/dist/esm/core/factories/define-website-section.d.ts +3 -3
  120. package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
  121. package/dist/esm/core/factories/define-website-section.js +3 -3
  122. package/dist/esm/core/factories/define-website-section.js.map +1 -1
  123. package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
  124. package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
  125. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  126. package/dist/esm/core/layouts/WebsitePageLayout.js +10 -7
  127. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  128. package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
  129. package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
  130. package/dist/esm/index.d.ts +30 -65
  131. package/dist/esm/index.d.ts.map +1 -1
  132. package/dist/esm/index.js +30 -65
  133. package/dist/esm/index.js.map +1 -1
  134. package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
  135. package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
  136. package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
  137. package/dist/esm/mdx/index.d.ts.map +1 -1
  138. package/dist/esm/mdx.d.ts.map +1 -1
  139. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
  140. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
  141. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
  142. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
  143. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
  144. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -1
  145. package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
  146. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
  147. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
  148. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
  149. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
  150. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
  151. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +9 -8
  152. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
  153. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
  154. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
  155. package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
  156. package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
  157. package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
  158. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
  159. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
  160. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
  161. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
  162. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
  163. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
  164. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
  165. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
  166. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
  167. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
  168. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
  169. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
  170. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
  171. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
  172. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
  173. package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
  174. package/dist/tsconfig.build.tsbuildinfo +1 -1
  175. package/package.json +5 -5
  176. package/src/__tests__/bundle-isolation.test.ts +19 -22
  177. package/src/astro/__tests__/blog-post-bridge.test.tsx +18 -2
  178. package/src/astro/__tests__/bridge-runtime.test.tsx +21 -3
  179. package/src/astro/__tests__/label-pack-loader.test.ts +7 -4
  180. package/src/astro/blog-post-bridge.tsx +19 -5
  181. package/src/astro/bridge-runtime.tsx +35 -11
  182. package/src/astro/label-pack-loader.ts +8 -4
  183. package/src/astro/robots-renderer.ts +15 -15
  184. package/src/astro/website-page-head.renderer.ts +2 -2
  185. package/src/astro/website-site-context.ts +5 -2
  186. package/src/companion-exports.ts +8 -15
  187. package/src/config/__tests__/website-build-origins.test.ts +52 -0
  188. package/src/config/define-website-config.ts +10 -10
  189. package/src/config/load-website-config.ts +25 -14
  190. package/src/config/load-website-pricing-catalog.ts +52 -0
  191. package/src/config/load-website-site-context.ts +145 -0
  192. package/src/config-loader.ts +2 -0
  193. package/src/core/__tests__/contexts.test.tsx +20 -1
  194. package/src/core/__tests__/website-consent.test.tsx +2 -1
  195. package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
  196. package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
  197. package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
  198. package/src/core/anonymous-session/inbound-contact-form.schema.ts +16 -4
  199. package/src/core/anonymous-session/website-anonymous-session-client.ts +39 -4
  200. package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
  201. package/src/core/consent/WebsiteConsentBanner.tsx +11 -10
  202. package/src/core/contexts/WebsiteRuntimeContext.tsx +25 -5
  203. package/src/core/contexts/useWebsiteRuntime.ts +33 -2
  204. package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
  205. package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
  206. package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
  207. package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
  208. package/src/core/external-providers/provider-component-registry.website.ts +115 -0
  209. package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
  210. package/src/core/external-providers/useWebsiteProviderScripts.ts +56 -37
  211. package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
  212. package/src/core/factories/define-website-section.ts +3 -3
  213. package/src/core/layouts/WebsitePageLayout.tsx +11 -6
  214. package/src/index.ts +31 -65
  215. package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
  216. package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
  217. package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
  218. package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
  219. package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +12 -0
  220. package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
  221. package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
  222. package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
  223. package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
  224. package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
  225. package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +7 -5
@@ -126,6 +126,30 @@ export interface LoadWebsiteConfigArgs {
126
126
  saasConfig: WildoSaasConfig;
127
127
  }
128
128
 
129
+ /**
130
+ * The application's one `STATIC_WEBSITE` service, as `[serviceKey, service]`.
131
+ * Throws when none is declared: the service folder is what owns
132
+ * `wildo.website.config.ts`, so a website build without one has nothing to load.
133
+ */
134
+ export function resolveStaticWebsiteServiceEntry(
135
+ saasConfig: WildoSaasConfig,
136
+ ): [string, WildoSaasConfig['services'][string]] {
137
+ const websiteServiceEntry = Object.entries(saasConfig.services).find(
138
+ ([, service]) => service.frontendType === AppFrontendType.STATIC_WEBSITE,
139
+ );
140
+ if (!websiteServiceEntry) {
141
+ throw new Error(
142
+ '[wildo-website-config] No frontend service with `frontendType: STATIC_WEBSITE` is declared in '
143
+ + 'wildo.saas.config.ts. The marketing-website Astro build / loader requires an authored '
144
+ + 'STATIC_WEBSITE service because that service folder owns the per-service '
145
+ + '`wildo.website.config.ts` (publicOrigin, appOrigin, defaultLocale, locales). '
146
+ + 'Add a STATIC_WEBSITE service in wildo.saas.config.ts and ship the per-service '
147
+ + 'config file under it.',
148
+ );
149
+ }
150
+ return websiteServiceEntry;
151
+ }
152
+
129
153
  /**
130
154
  * Discover and load the per-service marketing-website config.
131
155
  *
@@ -169,20 +193,7 @@ export async function loadWebsiteConfig(
169
193
  args: LoadWebsiteConfigArgs,
170
194
  ): Promise<WildoMarketingWebsiteConfig> {
171
195
  const { saasRoot, saasConfig } = args;
172
-
173
- const websiteServiceEntry = Object.entries(saasConfig.services).find(
174
- ([, service]) => service.frontendType === AppFrontendType.STATIC_WEBSITE,
175
- );
176
- if (!websiteServiceEntry) {
177
- throw new Error(
178
- '[wildo-website-config] No frontend service with `frontendType: STATIC_WEBSITE` is declared in '
179
- + 'wildo.saas.config.ts. The marketing-website Astro build / loader requires an authored '
180
- + 'STATIC_WEBSITE service because that service folder owns the per-service '
181
- + '`wildo.website.config.ts` (publicOrigin, appOrigin, defaultLocale, locales). '
182
- + 'Add a STATIC_WEBSITE service in wildo.saas.config.ts and ship the per-service '
183
- + 'config file under it.',
184
- );
185
- }
196
+ const websiteServiceEntry = resolveStaticWebsiteServiceEntry(saasConfig);
186
197
 
187
198
  const [websiteServiceKey, websiteService] = websiteServiceEntry;
188
199
  const websiteConfigPath = resolvePath(
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @wildo_source:part:start saas.website.config.pricing-catalog-loader facet:layer:astro facet:family:website facet:audience:app-developer
3
+ *
4
+ * `loadWebsitePricingCatalog` — the application's public pricing catalogue, for a website's pricing
5
+ * page (#1835). Call it in the page's `.astro` frontmatter, pass the result to the bridge island as
6
+ * `pricingCatalog`, and read it in the section with `useWebsitePricingCatalog()`; build the page's
7
+ * `AggregateOffer` from the same value with `buildWebsitePricingOfferStructuredData`.
8
+ *
9
+ * It walks up to `wildo.saas.config.ts` the way `loadWebsiteSiteContext` does, and reads the billing
10
+ * catalogue the backend sells from — the `productDefinitions` of the application's shared modules —
11
+ * through `loadApplicationPublicPricingCatalog`, from SOURCE, so a website image that builds only the
12
+ * website still sees the current catalogue.
13
+ *
14
+ * Separate from `loadWebsiteSiteContext` on purpose: loading the shared graph costs seconds, and only a
15
+ * pricing page needs it. Every other page's build stays as fast as it was.
16
+ *
17
+ * **Node-only.** Lives on the `/config-loader` subpath; it must never reach a React-island bundle.
18
+ */
19
+ import {
20
+ loadApplicationPublicPricingCatalog,
21
+ loadConfig,
22
+ resolveWorkspaceScope,
23
+ type WildoSaasConfig,
24
+ } from '@wildo-ai/platform-config-lib';
25
+ import type { PublicPricingCatalog } from '@wildo-ai/saas-models/public-runtime';
26
+
27
+ export interface LoadWebsitePricingCatalogInput {
28
+ /** Where to start the walk up to `wildo.saas.config.ts`. Defaults to the working directory. */
29
+ readonly cwd?: string | undefined;
30
+ /**
31
+ * Keep only plans this scope may subscribe to (a `ResourcePrimaryScope` wire value, e.g.
32
+ * `'organizations'`). Omit when the catalogue sells to one audience.
33
+ */
34
+ readonly targetScope?: string | undefined;
35
+ }
36
+
37
+ export async function loadWebsitePricingCatalog(input: LoadWebsitePricingCatalogInput = {}): Promise<PublicPricingCatalog> {
38
+ const workspaceScope = resolveWorkspaceScope(input.cwd);
39
+ if (workspaceScope.saasRoot === null || workspaceScope.saasConfigPath === null) {
40
+ throw new Error(
41
+ `[saas-website] No wildo.saas.config.ts found walking up from ${input.cwd ?? process.cwd()}. `
42
+ + 'Run astro from inside the application workspace.',
43
+ );
44
+ }
45
+ const saasConfig = await loadConfig<WildoSaasConfig>(workspaceScope.saasConfigPath);
46
+ return loadApplicationPublicPricingCatalog({
47
+ saasRoot: workspaceScope.saasRoot,
48
+ saasConfig,
49
+ ...(input.targetScope === undefined ? {} : { targetScope: input.targetScope }),
50
+ });
51
+ }
52
+ /** @wildo_source:part:end saas.website.config.pricing-catalog-loader */
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @wildo_source:part:start saas.website.config.site-context-loader facet:layer:astro facet:family:website facet:audience:app-developer
3
+ *
4
+ * `loadWebsiteSiteContext` — the ONE way a website workspace builds its
5
+ * `WebsiteSiteContext`. Called once, from the workspace's
6
+ * `src/website-site-context.ts`:
7
+ *
8
+ * ```ts
9
+ * import { loadWebsiteSiteContext } from '@wildo-ai/saas-website/config-loader';
10
+ * import { WEBSITE_ROOT_CONFIG } from './website-root.config';
11
+ *
12
+ * export const WEBSITE_SITE_CONTEXT = await loadWebsiteSiteContext({
13
+ * rootConfig: WEBSITE_ROOT_CONFIG,
14
+ * isDevelopment: import.meta.env?.DEV === true,
15
+ * });
16
+ * ```
17
+ *
18
+ * It walks up from the working directory to `wildo.saas.config.ts`, finds the
19
+ * application's single `STATIC_WEBSITE` service, loads that service's
20
+ * `wildo.website.config.ts` and its materialized provider block, and settles the
21
+ * two origins the site reaches the product through (see
22
+ * {@link resolveWebsiteBuildOrigins}).
23
+ *
24
+ * **Why the engine owns this.** Every website workspace used to carry its own
25
+ * copy of this lookup — the scaffolded ones and the hand-written ones alike —
26
+ * and each copy hardcoded its local API port while the app origin had no local
27
+ * form at all. The result: under `astro dev`, every "Start free" CTA pointed at a
28
+ * production-shaped host that resolves to nothing. One loader means one rule.
29
+ *
30
+ * **Why it walks up from the working directory, never from `import.meta.url`.**
31
+ * Astro's prerender pass bundles the calling module into `dist/.prerender/chunks/…`,
32
+ * where a `new URL('../..', import.meta.url)` anchor resolves to `dist/` rather than
33
+ * the application root — every prerendered route then failed with "Config file not
34
+ * found: …/website/dist/wildo.saas.config.ts", while `astro dev` hid it by serving
35
+ * from `src/`. Every `astro dev` / `astro build` runs with the website package (or
36
+ * an ancestor) as its working directory, which is inside the application either way.
37
+ *
38
+ * **Node-only.** Lives on the `/config-loader` subpath with the other loaders; it
39
+ * must never reach a React-island bundle.
40
+ */
41
+ import { resolve as resolvePath } from 'node:path';
42
+
43
+ import {
44
+ loadConfig,
45
+ loadMaterializedFrontendProviders,
46
+ resolveFrontendServicesArtifactPath,
47
+ resolveLocalhostApplicationOrigins,
48
+ resolveWorkspaceScope,
49
+ type LocalhostApplicationOrigins,
50
+ type WildoSaasConfig,
51
+ } from '@wildo-ai/platform-config-lib';
52
+
53
+ import { composeWebsiteSiteContext, type WebsiteSiteContext } from '../astro/website-site-context';
54
+ import type { WebsiteRootConfig } from '../schemas/manifests/website-root-config.shared.schemas';
55
+ import { loadWebsiteConfig, resolveStaticWebsiteServiceEntry } from './load-website-config';
56
+ import type { WildoMarketingWebsiteConfig } from './wildo-website-config.schemas';
57
+
58
+ /** Explicit per-build origin overrides — what a deploy build passes as build arguments. */
59
+ export interface WebsiteOriginOverrides {
60
+ /** `PUBLIC_APP_ORIGIN`: this environment's product origin, derived by the deploy workflow. */
61
+ readonly appOrigin?: string | undefined;
62
+ /** `PUBLIC_API_ORIGIN`: this environment's API origin. */
63
+ readonly apiOrigin?: string | undefined;
64
+ }
65
+
66
+ export interface ResolveWebsiteBuildOriginsInput {
67
+ /** The authored `wildo.website.config.ts` — the production default. */
68
+ readonly websiteConfig: WildoMarketingWebsiteConfig;
69
+ /** `true` under `astro dev`. */
70
+ readonly isDevelopment: boolean;
71
+ /** Where the product and API run locally, from the app's own declared ports. */
72
+ readonly localOrigins: LocalhostApplicationOrigins;
73
+ /** Explicit overrides, which win over everything. */
74
+ readonly overrides: WebsiteOriginOverrides;
75
+ }
76
+
77
+ /**
78
+ * The `websiteConfig` this BUILD serves, with `appOrigin` and `apiOrigin` settled.
79
+ *
80
+ * Precedence, per origin:
81
+ *
82
+ * 1. an explicit override (`PUBLIC_APP_ORIGIN` / `PUBLIC_API_ORIGIN`) — the deploy
83
+ * workflow derives these per environment, so a staging build links into staging;
84
+ * 2. under `astro dev`, the locally running product / API, from the ports the
85
+ * application declares — so a CTA reaches the process `wildo local dev` started;
86
+ * 3. the value authored in `wildo.website.config.ts`.
87
+ *
88
+ * The result replaces the two fields in `websiteConfig` rather than living beside
89
+ * it, because every page already forwards `websiteConfig.appOrigin` /
90
+ * `websiteConfig.apiOrigin` into its island: a second field would be a second
91
+ * source that pages could read instead. `websiteConfig` in a site context is
92
+ * therefore "the config as it applies to this build", which the config file was
93
+ * already half-way to being — it reads `PUBLIC_APP_ORIGIN` itself.
94
+ */
95
+ export function resolveWebsiteBuildOrigins(input: ResolveWebsiteBuildOriginsInput): WildoMarketingWebsiteConfig {
96
+ const { websiteConfig, isDevelopment, localOrigins, overrides } = input;
97
+ const settle = (override: string | undefined, local: string | undefined, authored: string | undefined): string | undefined =>
98
+ override || (isDevelopment ? local : undefined) || authored;
99
+ const appOrigin = settle(overrides.appOrigin, localOrigins.app, websiteConfig.appOrigin) ?? websiteConfig.appOrigin;
100
+ const apiOrigin = settle(overrides.apiOrigin, localOrigins.api, websiteConfig.apiOrigin);
101
+ return { ...websiteConfig, appOrigin, ...(apiOrigin === undefined ? {} : { apiOrigin }) };
102
+ }
103
+
104
+ export interface LoadWebsiteSiteContextInput {
105
+ /** The workspace's `WEBSITE_ROOT_CONFIG`. */
106
+ readonly rootConfig: WebsiteRootConfig;
107
+ /** Pass `import.meta.env?.DEV === true` (optional chaining: `astro.config.ts` evaluates the caller before Vite's env exists). */
108
+ readonly isDevelopment: boolean;
109
+ /** Where to start the walk up to `wildo.saas.config.ts`. Defaults to the working directory. */
110
+ readonly cwd?: string | undefined;
111
+ /** Defaults to `PUBLIC_APP_ORIGIN` / `PUBLIC_API_ORIGIN` from the process environment. */
112
+ readonly overrides?: WebsiteOriginOverrides | undefined;
113
+ }
114
+
115
+ export async function loadWebsiteSiteContext(input: LoadWebsiteSiteContextInput): Promise<WebsiteSiteContext> {
116
+ const workspaceScope = resolveWorkspaceScope(input.cwd);
117
+ if (workspaceScope.saasRoot === null || workspaceScope.saasConfigPath === null) {
118
+ throw new Error(
119
+ `[saas-website] No wildo.saas.config.ts found walking up from ${input.cwd ?? process.cwd()}. `
120
+ + 'Run astro from inside the application workspace.',
121
+ );
122
+ }
123
+ const saasRoot = workspaceScope.saasRoot;
124
+ const saasConfig = await loadConfig<WildoSaasConfig>(workspaceScope.saasConfigPath);
125
+ const [websiteServiceKey] = resolveStaticWebsiteServiceEntry(saasConfig);
126
+ const websiteConfig = await loadWebsiteConfig({ saasRoot, saasConfig });
127
+
128
+ return composeWebsiteSiteContext({
129
+ rootConfig: input.rootConfig,
130
+ websiteConfig: resolveWebsiteBuildOrigins({
131
+ websiteConfig,
132
+ isDevelopment: input.isDevelopment,
133
+ localOrigins: resolveLocalhostApplicationOrigins(saasConfig.services),
134
+ overrides: input.overrides ?? {
135
+ appOrigin: process.env.PUBLIC_APP_ORIGIN,
136
+ apiOrigin: process.env.PUBLIC_API_ORIGIN,
137
+ },
138
+ }),
139
+ frontendProviders: loadMaterializedFrontendProviders({
140
+ artifactPath: resolveFrontendServicesArtifactPath(resolvePath(saasRoot)),
141
+ serviceKey: websiteServiceKey,
142
+ }),
143
+ });
144
+ }
145
+ /** @wildo_source:part:end saas.website.config.site-context-loader */
@@ -33,3 +33,5 @@
33
33
  */
34
34
 
35
35
  export * from './config/load-website-config';
36
+ export * from './config/load-website-site-context';
37
+ export * from './config/load-website-pricing-catalog';
@@ -7,7 +7,7 @@ import {
7
7
  WebsiteRuntimeContextProvider,
8
8
  type WebsiteRuntimeContextValue,
9
9
  } from '../contexts/WebsiteRuntimeContext';
10
- import { useWebsiteRuntime } from '../contexts/useWebsiteRuntime';
10
+ import { useWebsitePricingCatalog, useWebsiteRuntime } from '../contexts/useWebsiteRuntime';
11
11
  import { WebsitePageContextProvider } from '../contexts/WebsitePageContext';
12
12
  import { useWebsitePage } from '../contexts/useWebsitePage';
13
13
  import { WebsiteSectionContextProvider } from '../contexts/WebsiteSectionContext';
@@ -76,6 +76,25 @@ describe('WebsiteRuntimeContext', () => {
76
76
  });
77
77
  });
78
78
 
79
+ describe('useWebsitePricingCatalog', () => {
80
+ const wrapperWith = (value: WebsiteRuntimeContextValue) => ({ children }: { children: React.ReactNode }) => (
81
+ <WebsiteRuntimeContextProvider value={value}>{children}</WebsiteRuntimeContextProvider>
82
+ );
83
+
84
+ it('returns the catalogue the page passed to its island', () => {
85
+ const catalog = { plans: [] };
86
+ const { result } = renderHook(() => useWebsitePricingCatalog(), { wrapper: wrapperWith({ ...RUNTIME_FIXTURE, pricingCatalog: catalog }) });
87
+ expect(result.current).toBe(catalog);
88
+ });
89
+
90
+ it('fails loudly when the page passed none — a pricing section must never render "no plans" for a missing prop', () => {
91
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
92
+ expect(() => renderHook(() => useWebsitePricingCatalog(), { wrapper: wrapperWith(RUNTIME_FIXTURE) })).toThrowError(
93
+ /did not pass a pricing catalogue/,
94
+ );
95
+ });
96
+ });
97
+
79
98
  describe('WebsitePageContext', () => {
80
99
  it('provides the page ref through the provider component', () => {
81
100
  const pageRef = WebsitePageRefSchema.parse('landing');
@@ -56,7 +56,8 @@ function provider(ref: string, capability: string, activate: ReturnType<typeof v
56
56
  metadata: defineProviderMetadata({ ref, packageName: '@test/providers', tier: ProviderTier.EXTENSION, origin: { kind: ProviderOriginKind.APPLICATION } }),
57
57
  providerCapabilities: [capability],
58
58
  protocols: ['FRONTEND_SDK'],
59
- sdk: { descriptor: { packageName: `@vendor/${ref}` }, activate },
59
+ // Declared stoppable: a consent-based SDK must be, or it never starts (#1704).
60
+ sdk: { descriptor: { packageName: `@vendor/${ref}` }, stoppable: true, activate },
60
61
  };
61
62
  }
62
63
 
@@ -8,15 +8,41 @@
8
8
  * the anonymous session (init header) and the backend stamps the lead's `anonymousUserId`
9
9
  * so it can later transpose to the visitor's account on signup/login.
10
10
  *
11
- * Labels are injected (optional props with English fallbacks) rather than read from the
12
- * website runtime context, so the component stays framework-light and unit-testable; the
13
- * section wrapper passes i18n-resolved strings.
11
+ * Labels are injected rather than read from the website runtime context, so the component stays framework-light and
12
+ * unit-testable; the section wrapper passes the strings it resolved through `useWebsiteLabel`. EVERY label is required
13
+ * and the form carries no words of its own (#1986): a fallback would be English in every locale and would hide the
14
+ * missing label, while a required prop makes an omission a type error in the section, and a key the pack does not
15
+ * carry reaches the page as the key itself — which is what `useWebsiteLabel` returns, so somebody authors it.
16
+ *
17
+ * Submission challenge (#1735): when the application protects the form with a captcha, each submission carries an
18
+ * answer — the consented vendor's token when its browser part runs on the page, otherwise the engine's proof of work,
19
+ * which needs no consent (see `website-submission-challenge.ts`). Nothing for the visitor to click or read: both are
20
+ * invisible, so the form stays operable by keyboard and screen reader exactly as before. A vendor token the backend
21
+ * refuses is retried once with the proof of work; a refusal that survives that shows `challengeRefusedMessage`.
14
22
  */
15
23
 
16
- import React, { useCallback, useMemo, useState } from "react";
17
- import { InboundContactFormSchema, buildInboundContactCreateBody, type InboundContactFormValues } from "./inbound-contact-form.schema";
24
+ import React, { useCallback, useState } from "react";
25
+ import { SubmissionChallengeAction, WildoHeaderKeys } from "@wildo-ai/saas-models/public-runtime";
26
+ import {
27
+ INBOUND_CONTACT_NAME_MAX_LENGTH,
28
+ INBOUND_CONTACT_PHONE_MAX_LENGTH,
29
+ InboundContactFormSchema,
30
+ buildInboundContactCreateBody,
31
+ type InboundContactFormValues,
32
+ } from "./inbound-contact-form.schema";
18
33
  import type { WebsiteAnonymousSessionClient } from "./website-anonymous-session-client";
34
+ import {
35
+ isSubmissionChallengeRefusal,
36
+ obtainSubmissionChallengeAnswer,
37
+ useWebsiteSubmissionChallengeSource,
38
+ type WebsiteSubmissionChallengeSource,
39
+ } from "./website-submission-challenge";
19
40
 
41
+ /**
42
+ * Every word the form shows. All required (#1986): the section resolves each through `useWebsiteLabel` and lists its
43
+ * key in `expectedLabelKeys`, so a missing label is a type error in the section or a visible key on the page, never a
44
+ * silent English substitute.
45
+ */
20
46
  export interface InboundContactFormLabels {
21
47
  emailLabel: string;
22
48
  firstNameLabel: string;
@@ -28,29 +54,23 @@ export interface InboundContactFormLabels {
28
54
  submittingLabel: string;
29
55
  successMessage: string;
30
56
  errorMessage: string;
57
+ /** The anti-spam check did not pass, even with the proof of work (#1735). */
58
+ challengeRefusedMessage: string;
59
+ /** Shown beside the email field when what was typed is not an email address (#1986). */
60
+ invalidEmailMessage: string;
31
61
  }
32
62
 
33
- const DEFAULT_LABELS: InboundContactFormLabels = {
34
- emailLabel: "Email",
35
- firstNameLabel: "First name",
36
- lastNameLabel: "Last name",
37
- phoneLabel: "Phone",
38
- marketingConsentLabel: "Keep me updated about news and offers",
39
- submitLabel: "Get in touch",
40
- submittingLabel: "Sending…",
41
- successMessage: "Thanks — we'll be in touch shortly.",
42
- errorMessage: "Something went wrong. Please try again.",
43
- };
44
-
45
63
  type SubmitState = { status: "idle" } | { status: "submitting" } | { status: "success" } | { status: "error"; message: string };
46
64
 
47
65
  export interface InboundContactFormProps {
48
66
  /** The anonymous-session client (constructed by the section wiring from site config). */
49
67
  client: WebsiteAnonymousSessionClient;
50
- /** Optional i18n-resolved labels; English fallbacks are used per-field when omitted. */
51
- labels?: Partial<InboundContactFormLabels>;
68
+ /** The section's i18n-resolved labels — every one of them (#1986). */
69
+ labels: InboundContactFormLabels;
52
70
  /** Optional callback fired after a successful submission (e.g. analytics). */
53
71
  onSubmitted?: () => void;
72
+ /** The page's captcha, for a form rendered outside a page layout (tests). Defaults to the page's own. */
73
+ challengeSource?: WebsiteSubmissionChallengeSource;
54
74
  }
55
75
 
56
76
  const EMPTY_VALUES: InboundContactFormValues = {
@@ -61,10 +81,11 @@ const EMPTY_VALUES: InboundContactFormValues = {
61
81
  marketingConsent: false,
62
82
  };
63
83
 
64
- export function InboundContactForm({ client, labels, onSubmitted }: InboundContactFormProps): React.ReactElement {
65
- const resolvedLabels = useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]);
84
+ export function InboundContactForm({ client, labels, onSubmitted, challengeSource }: InboundContactFormProps): React.ReactElement {
85
+ const pageChallengeSource = useWebsiteSubmissionChallengeSource();
86
+ const source = challengeSource ?? pageChallengeSource;
66
87
  const [values, setValues] = useState<InboundContactFormValues>(EMPTY_VALUES);
67
- const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof InboundContactFormValues, string>>>({});
88
+ const [emailInvalid, setEmailInvalid] = useState(false);
68
89
  const [submit, setSubmit] = useState<SubmitState>({ status: "idle" });
69
90
 
70
91
  const setField = useCallback(<K extends keyof InboundContactFormValues>(key: K, value: InboundContactFormValues[K]) => {
@@ -74,38 +95,54 @@ export function InboundContactForm({ client, labels, onSubmitted }: InboundConta
74
95
  const handleSubmit = useCallback(
75
96
  async (event: React.FormEvent<HTMLFormElement>) => {
76
97
  event.preventDefault();
77
- setFieldErrors({});
98
+ setEmailInvalid(false);
78
99
 
79
100
  const parsed = InboundContactFormSchema.safeParse(values);
80
101
  if (!parsed.success) {
81
- const nextErrors: Partial<Record<keyof InboundContactFormValues, string>> = {};
82
- for (const issue of parsed.error.issues) {
83
- const key = issue.path[0] as keyof InboundContactFormValues | undefined;
84
- if (key && !nextErrors[key]) nextErrors[key] = issue.message;
85
- }
86
- setFieldErrors(nextErrors);
102
+ // The schema's own issue messages are never shown: they are English and nobody authors them (#1986). The email
103
+ // is the one field a visitor can get wrong, and it has its label. The other fields' only rule is a length the
104
+ // inputs already enforce with `maxLength`, so reaching a failure there means the values did not come from
105
+ // typing, and the form answers with its generic error rather than inventing a sentence.
106
+ const emailFailed = parsed.error.issues.some((issue) => issue.path[0] === "email");
107
+ setEmailInvalid(emailFailed);
108
+ if (!emailFailed) setSubmit({ status: "error", message: labels.errorMessage });
87
109
  return;
88
110
  }
89
111
 
90
112
  setSubmit({ status: "submitting" });
113
+ const body = buildInboundContactCreateBody(parsed.data);
114
+ const send = async (proofOfWorkOnly: boolean): Promise<void> => {
115
+ const answer = await obtainSubmissionChallengeAnswer({ source, action: SubmissionChallengeAction.INBOUND_CONTACT, client, proofOfWorkOnly });
116
+ await client.postAnonymous("/inbound-contacts", body, answer === undefined ? undefined : { [WildoHeaderKeys.SUBMISSION_CHALLENGE_RESPONSE]: answer });
117
+ };
91
118
  try {
92
- await client.postAnonymous("/inbound-contacts", buildInboundContactCreateBody(parsed.data));
119
+ try {
120
+ await send(false);
121
+ } catch (error) {
122
+ // A refused challenge is answered ONCE more, with the proof of work: a vendor that scored a real person low,
123
+ // could not be reached, or a site that did not know the backend protects the form must not lock anyone out.
124
+ if (!isSubmissionChallengeRefusal(error)) throw error;
125
+ await send(true);
126
+ }
93
127
  setSubmit({ status: "success" });
94
128
  setValues(EMPTY_VALUES);
95
129
  onSubmitted?.();
96
- } catch {
130
+ } catch (error) {
97
131
  // The backend-only `emailUserMatch` triage signal is never returned here, so a
98
132
  // failure exposes no account-existence information — just a generic retry.
99
- setSubmit({ status: "error", message: resolvedLabels.errorMessage });
133
+ setSubmit({
134
+ status: "error",
135
+ message: isSubmissionChallengeRefusal(error) ? labels.challengeRefusedMessage : labels.errorMessage,
136
+ });
100
137
  }
101
138
  },
102
- [client, values, onSubmitted, resolvedLabels.errorMessage],
139
+ [client, values, onSubmitted, source, labels.errorMessage, labels.challengeRefusedMessage],
103
140
  );
104
141
 
105
142
  if (submit.status === "success") {
106
143
  return (
107
144
  <div role="status" aria-live="polite">
108
- {resolvedLabels.successMessage}
145
+ {labels.successMessage}
109
146
  </div>
110
147
  );
111
148
  }
@@ -115,42 +152,60 @@ export function InboundContactForm({ client, labels, onSubmitted }: InboundConta
115
152
  return (
116
153
  <form onSubmit={handleSubmit} noValidate>
117
154
  <label>
118
- {resolvedLabels.emailLabel}
155
+ {labels.emailLabel}
119
156
  <input
120
157
  type="email"
121
158
  name="email"
122
159
  required
123
160
  value={values.email}
124
161
  onChange={(e) => setField("email", e.target.value)}
125
- aria-invalid={fieldErrors.email ? true : undefined}
162
+ aria-invalid={emailInvalid ? true : undefined}
126
163
  />
127
- {fieldErrors.email ? <span role="alert">{fieldErrors.email}</span> : null}
164
+ {emailInvalid ? <span role="alert">{labels.invalidEmailMessage}</span> : null}
128
165
  </label>
129
166
 
130
167
  <label>
131
- {resolvedLabels.firstNameLabel}
132
- <input type="text" name="firstName" value={values.firstName ?? ""} onChange={(e) => setField("firstName", e.target.value)} />
168
+ {labels.firstNameLabel}
169
+ <input
170
+ type="text"
171
+ name="firstName"
172
+ maxLength={INBOUND_CONTACT_NAME_MAX_LENGTH}
173
+ value={values.firstName ?? ""}
174
+ onChange={(e) => setField("firstName", e.target.value)}
175
+ />
133
176
  </label>
134
177
 
135
178
  <label>
136
- {resolvedLabels.lastNameLabel}
137
- <input type="text" name="lastName" value={values.lastName ?? ""} onChange={(e) => setField("lastName", e.target.value)} />
179
+ {labels.lastNameLabel}
180
+ <input
181
+ type="text"
182
+ name="lastName"
183
+ maxLength={INBOUND_CONTACT_NAME_MAX_LENGTH}
184
+ value={values.lastName ?? ""}
185
+ onChange={(e) => setField("lastName", e.target.value)}
186
+ />
138
187
  </label>
139
188
 
140
189
  <label>
141
- {resolvedLabels.phoneLabel}
142
- <input type="tel" name="phone" value={values.phone ?? ""} onChange={(e) => setField("phone", e.target.value)} />
190
+ {labels.phoneLabel}
191
+ <input
192
+ type="tel"
193
+ name="phone"
194
+ maxLength={INBOUND_CONTACT_PHONE_MAX_LENGTH}
195
+ value={values.phone ?? ""}
196
+ onChange={(e) => setField("phone", e.target.value)}
197
+ />
143
198
  </label>
144
199
 
145
200
  <label>
146
201
  <input type="checkbox" name="marketingConsent" checked={values.marketingConsent} onChange={(e) => setField("marketingConsent", e.target.checked)} />
147
- {resolvedLabels.marketingConsentLabel}
202
+ {labels.marketingConsentLabel}
148
203
  </label>
149
204
 
150
205
  {submit.status === "error" ? <div role="alert">{submit.message}</div> : null}
151
206
 
152
207
  <button type="submit" disabled={isSubmitting}>
153
- {isSubmitting ? resolvedLabels.submittingLabel : resolvedLabels.submitLabel}
208
+ {isSubmitting ? labels.submittingLabel : labels.submitLabel}
154
209
  </button>
155
210
  </form>
156
211
  );
@@ -0,0 +1,108 @@
1
+ /**
2
+ * #1986 — every word the contact form shows is one its section passed.
3
+ *
4
+ * The form used to merge an English `DEFAULT_LABELS` under optional props, and showed the schema's own English issue
5
+ * message beside the email field. Both reached every locale and neither could be found by whoever authors the label
6
+ * pack. These pins walk the rendered text, so a literal put back into the markup, the schema or a fallback reddens them.
7
+ */
8
+ import React from 'react';
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
+ import '@testing-library/jest-dom';
12
+
13
+ import { InboundContactForm, type InboundContactFormLabels } from '../InboundContactForm';
14
+ import { INBOUND_CONTACT_NAME_MAX_LENGTH, INBOUND_CONTACT_PHONE_MAX_LENGTH } from '../inbound-contact-form.schema';
15
+ import type { WebsiteAnonymousSessionClient } from '../website-anonymous-session-client';
16
+
17
+ afterEach(() => { cleanup(); });
18
+
19
+ const LABELS: InboundContactFormLabels = {
20
+ emailLabel: 'email-label',
21
+ firstNameLabel: 'first-name-label',
22
+ lastNameLabel: 'last-name-label',
23
+ phoneLabel: 'phone-label',
24
+ marketingConsentLabel: 'marketing-consent-label',
25
+ submitLabel: 'submit-label',
26
+ submittingLabel: 'submitting-label',
27
+ successMessage: 'success-label',
28
+ errorMessage: 'error-label',
29
+ challengeRefusedMessage: 'refused-label',
30
+ invalidEmailMessage: 'invalid-email-label',
31
+ };
32
+
33
+ const AUTHORED = new Set<string>(Object.values(LABELS));
34
+
35
+ /** Every non-blank text node under `root`, trimmed. */
36
+ function renderedWords(root: HTMLElement): string[] {
37
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
38
+ const words: string[] = [];
39
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
40
+ const text = node.textContent?.trim();
41
+ if (text) words.push(text);
42
+ }
43
+ return words;
44
+ }
45
+
46
+ function clientDouble() {
47
+ const postAnonymous = vi.fn(async () => ({}));
48
+ return { client: { postAnonymous, getPublic: vi.fn() } as unknown as WebsiteAnonymousSessionClient, postAnonymous };
49
+ }
50
+
51
+ function renderForm() {
52
+ const double = clientDouble();
53
+ const view = render(<InboundContactForm client={double.client} challengeSource={{ captchaDeclared: false }} labels={LABELS} />);
54
+ return { ...double, container: view.container };
55
+ }
56
+
57
+ describe('InboundContactForm — its words are the section\'s (#1986)', () => {
58
+ it('shows no word of its own in the idle form', () => {
59
+ const { container } = renderForm();
60
+ const words = renderedWords(container);
61
+ expect(words.length).toBeGreaterThan(0);
62
+ expect(words.filter((word) => !AUTHORED.has(word))).toEqual([]);
63
+ });
64
+
65
+ it('words an invalid email with the section\'s label, never the schema\'s message', async () => {
66
+ const { container, postAnonymous } = renderForm();
67
+ fireEvent.change(screen.getByRole('textbox', { name: LABELS.emailLabel }), { target: { value: 'not-an-email' } });
68
+ fireEvent.click(screen.getByRole('button', { name: LABELS.submitLabel }));
69
+ expect(await screen.findByRole('alert')).toHaveTextContent(LABELS.invalidEmailMessage);
70
+ expect(screen.getByRole('textbox', { name: new RegExp(LABELS.emailLabel) })).toHaveAttribute('aria-invalid', 'true');
71
+ expect(renderedWords(container).filter((word) => !AUTHORED.has(word))).toEqual([]);
72
+ expect(postAnonymous).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it('answers a length failure no typed input can produce with the generic error, not a schema sentence', async () => {
76
+ const { container, postAnonymous } = renderForm();
77
+ fireEvent.change(screen.getByRole('textbox', { name: new RegExp(LABELS.emailLabel) }), { target: { value: 'visitor@example.test' } });
78
+ // `fireEvent.change` sets the value directly, which is exactly the path `maxLength` cannot stop.
79
+ fireEvent.change(screen.getByRole('textbox', { name: LABELS.firstNameLabel }), { target: { value: 'x'.repeat(INBOUND_CONTACT_NAME_MAX_LENGTH + 1) } });
80
+ fireEvent.click(screen.getByRole('button', { name: LABELS.submitLabel }));
81
+ expect(await screen.findByRole('alert')).toHaveTextContent(LABELS.errorMessage);
82
+ expect(renderedWords(container).filter((word) => !AUTHORED.has(word) && !/^x+$/.test(word))).toEqual([]);
83
+ expect(postAnonymous).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('shows only the section\'s words once sent', async () => {
87
+ const { container } = renderForm();
88
+ fireEvent.change(screen.getByRole('textbox', { name: LABELS.emailLabel }), { target: { value: 'visitor@example.test' } });
89
+ fireEvent.click(screen.getByRole('button', { name: LABELS.submitLabel }));
90
+ await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
91
+ expect(renderedWords(container)).toEqual([LABELS.successMessage]);
92
+ });
93
+
94
+ it('stops typing at the schema\'s own length ceilings', () => {
95
+ renderForm();
96
+ expect(screen.getByRole('textbox', { name: LABELS.firstNameLabel })).toHaveAttribute('maxLength', String(INBOUND_CONTACT_NAME_MAX_LENGTH));
97
+ expect(screen.getByRole('textbox', { name: LABELS.lastNameLabel })).toHaveAttribute('maxLength', String(INBOUND_CONTACT_NAME_MAX_LENGTH));
98
+ expect(screen.getByRole('textbox', { name: LABELS.phoneLabel })).toHaveAttribute('maxLength', String(INBOUND_CONTACT_PHONE_MAX_LENGTH));
99
+ });
100
+
101
+ it('refuses, at compile time, a section that omits a label', () => {
102
+ const { client } = clientDouble();
103
+ const { invalidEmailMessage: _omitted, ...incomplete } = LABELS;
104
+ // @ts-expect-error — every label is required (#1986): an omission is the section's type error, not English.
105
+ const element = <InboundContactForm client={client} labels={incomplete} />;
106
+ expect(element).toBeTruthy();
107
+ });
108
+ });