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