@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.
- package/dist/esm/astro/blog-post-bridge.d.ts +9 -0
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +10 -5
- package/dist/esm/astro/blog-post-bridge.js.map +1 -1
- package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.d.ts +25 -6
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +10 -6
- package/dist/esm/astro/bridge-runtime.js.map +1 -1
- package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -1
- package/dist/esm/astro/headers-renderer.d.ts.map +1 -1
- package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.d.ts +4 -4
- package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.js +9 -4
- package/dist/esm/astro/label-pack-loader.js.map +1 -1
- package/dist/esm/astro/llms-txt-server.d.ts.map +1 -1
- package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.d.ts +9 -8
- package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.js +15 -15
- package/dist/esm/astro/robots-renderer.js.map +1 -1
- package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -1
- package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.js +2 -1
- package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
- package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -1
- package/dist/esm/astro/website-site-context.d.ts +5 -2
- package/dist/esm/astro/website-site-context.d.ts.map +1 -1
- package/dist/esm/astro/website-site-context.js.map +1 -1
- package/dist/esm/astro-island.d.ts.map +1 -1
- package/dist/esm/astro.d.ts.map +1 -1
- package/dist/esm/companion-exports.d.ts +8 -15
- package/dist/esm/companion-exports.d.ts.map +1 -1
- package/dist/esm/companion-exports.js +8 -15
- package/dist/esm/companion-exports.js.map +1 -1
- package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -1
- package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -1
- package/dist/esm/config/define-website-config.d.ts +3 -2
- package/dist/esm/config/define-website-config.d.ts.map +1 -1
- package/dist/esm/config/define-website-config.js +10 -10
- package/dist/esm/config/define-website-config.js.map +1 -1
- package/dist/esm/config/index.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.d.ts +6 -0
- package/dist/esm/config/load-website-config.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.js +18 -9
- package/dist/esm/config/load-website-config.js.map +1 -1
- package/dist/esm/config/load-website-pricing-catalog.d.ts +13 -0
- package/dist/esm/config/load-website-pricing-catalog.d.ts.map +1 -0
- package/dist/esm/config/load-website-pricing-catalog.js +34 -0
- package/dist/esm/config/load-website-pricing-catalog.js.map +1 -0
- package/dist/esm/config/load-website-site-context.d.ts +53 -0
- package/dist/esm/config/load-website-site-context.d.ts.map +1 -0
- package/dist/esm/config/load-website-site-context.js +98 -0
- package/dist/esm/config/load-website-site-context.js.map +1 -0
- package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
- package/dist/esm/config-loader.d.ts +2 -0
- package/dist/esm/config-loader.d.ts.map +1 -1
- package/dist/esm/config-loader.js +2 -0
- package/dist/esm/config-loader.js.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
- package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +11 -0
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +15 -4
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +14 -2
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +34 -4
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentBanner.js +11 -10
- package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -1
- package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -1
- package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +25 -5
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
- package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
- package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
- package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
- package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
- package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
- package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +15 -5
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +39 -24
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
- package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
- package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
- package/dist/esm/core/factories/define-website-section.d.ts +3 -3
- package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
- package/dist/esm/core/factories/define-website-section.js +3 -3
- package/dist/esm/core/factories/define-website-section.js.map +1 -1
- package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
- package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.js +10 -7
- package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
- package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
- package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
- package/dist/esm/index.d.ts +30 -65
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +30 -65
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
- package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
- package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
- package/dist/esm/mdx/index.d.ts.map +1 -1
- package/dist/esm/mdx.d.ts.map +1 -1
- package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
- package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +9 -8
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/__tests__/bundle-isolation.test.ts +19 -22
- package/src/astro/__tests__/blog-post-bridge.test.tsx +18 -2
- package/src/astro/__tests__/bridge-runtime.test.tsx +21 -3
- package/src/astro/__tests__/label-pack-loader.test.ts +7 -4
- package/src/astro/blog-post-bridge.tsx +19 -5
- package/src/astro/bridge-runtime.tsx +35 -11
- package/src/astro/label-pack-loader.ts +8 -4
- package/src/astro/robots-renderer.ts +15 -15
- package/src/astro/website-page-head.renderer.ts +2 -2
- package/src/astro/website-site-context.ts +5 -2
- package/src/companion-exports.ts +8 -15
- package/src/config/__tests__/website-build-origins.test.ts +52 -0
- package/src/config/define-website-config.ts +10 -10
- package/src/config/load-website-config.ts +25 -14
- package/src/config/load-website-pricing-catalog.ts +52 -0
- package/src/config/load-website-site-context.ts +145 -0
- package/src/config-loader.ts +2 -0
- package/src/core/__tests__/contexts.test.tsx +20 -1
- package/src/core/__tests__/website-consent.test.tsx +2 -1
- package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
- package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
- package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
- package/src/core/anonymous-session/inbound-contact-form.schema.ts +16 -4
- package/src/core/anonymous-session/website-anonymous-session-client.ts +39 -4
- package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
- package/src/core/consent/WebsiteConsentBanner.tsx +11 -10
- package/src/core/contexts/WebsiteRuntimeContext.tsx +25 -5
- package/src/core/contexts/useWebsiteRuntime.ts +33 -2
- package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
- package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
- package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
- package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
- package/src/core/external-providers/provider-component-registry.website.ts +115 -0
- package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
- package/src/core/external-providers/useWebsiteProviderScripts.ts +56 -37
- package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
- package/src/core/factories/define-website-section.ts +3 -3
- package/src/core/layouts/WebsitePageLayout.tsx +11 -6
- package/src/index.ts +31 -65
- package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
- package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
- package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
- package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
- package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +12 -0
- package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
- package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
- package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
- package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
- package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
- 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 */
|
package/src/config-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
17
|
-
import {
|
|
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
|
-
/**
|
|
51
|
-
labels
|
|
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
|
|
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 [
|
|
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
|
-
|
|
98
|
+
setEmailInvalid(false);
|
|
78
99
|
|
|
79
100
|
const parsed = InboundContactFormSchema.safeParse(values);
|
|
80
101
|
if (!parsed.success) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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({
|
|
133
|
+
setSubmit({
|
|
134
|
+
status: "error",
|
|
135
|
+
message: isSubmissionChallengeRefusal(error) ? labels.challengeRefusedMessage : labels.errorMessage,
|
|
136
|
+
});
|
|
100
137
|
}
|
|
101
138
|
},
|
|
102
|
-
[client, values, onSubmitted,
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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={
|
|
162
|
+
aria-invalid={emailInvalid ? true : undefined}
|
|
126
163
|
/>
|
|
127
|
-
{
|
|
164
|
+
{emailInvalid ? <span role="alert">{labels.invalidEmailMessage}</span> : null}
|
|
128
165
|
</label>
|
|
129
166
|
|
|
130
167
|
<label>
|
|
131
|
-
{
|
|
132
|
-
<input
|
|
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
|
-
{
|
|
137
|
-
<input
|
|
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
|
-
{
|
|
142
|
-
<input
|
|
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
|
-
{
|
|
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 ?
|
|
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
|
+
});
|