@wildo-ai/saas-website 1.1.5 → 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 +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/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/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/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/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-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/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/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/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-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/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/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/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/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-section.d.ts +3 -3
- 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/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/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/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-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-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/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/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
|
@@ -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
|
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider component on the website (#1703), through the real page bridge.
|
|
3
|
+
*
|
|
4
|
+
* What this pins, in order of how badly each would fail:
|
|
5
|
+
*
|
|
6
|
+
* 1. The server render ships NOTHING of the vendor — the loader is never called, and the HTML holds
|
|
7
|
+
* only the reserved box. Astro renders every island on the server; a component that ran there
|
|
8
|
+
* would put vendor markup into HTML a visitor downloads before consenting to anything.
|
|
9
|
+
* 2. One declaration serves the website: the provider module's `components[capability].load`, the
|
|
10
|
+
* same field the application reads, is what renders here.
|
|
11
|
+
* 3. A consent-based provider downloads nothing until the visitor agrees, shows an announced,
|
|
12
|
+
* labelled refusal with a real button, and loads in place once the visitor grants the purpose.
|
|
13
|
+
* 4. A failed load says so, in the site's words.
|
|
14
|
+
*/
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { renderToString } from 'react-dom/server';
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
18
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
19
|
+
import '@testing-library/jest-dom';
|
|
20
|
+
import {
|
|
21
|
+
AvailableLanguage,
|
|
22
|
+
ConsentPurpose,
|
|
23
|
+
ENGINE_PROVIDER_ORIGIN,
|
|
24
|
+
ProviderAddressLookupPlaceIdRetention,
|
|
25
|
+
ProviderTier,
|
|
26
|
+
defineFrontendPublicConfigSchema,
|
|
27
|
+
defineProviderMetadata,
|
|
28
|
+
type FrontendProvidersBlock,
|
|
29
|
+
type ProviderCapability,
|
|
30
|
+
} from '@wildo-ai/saas-models/public-runtime';
|
|
31
|
+
import { ProviderComponentStatus, type GeneratedWebsiteProviderModules } from '@wildo-ai/external-connectors-public/website';
|
|
32
|
+
|
|
33
|
+
import { createWebsitePageBridgeIsland } from '../../../astro/bridge-runtime';
|
|
34
|
+
import { WebsiteLabelKeySchema } from '../../../schemas/label-keys/website-label-key.schemas';
|
|
35
|
+
import {
|
|
36
|
+
WEBSITE_CONSENT_LABEL_KEYS,
|
|
37
|
+
WebsiteConsentLabelSlot,
|
|
38
|
+
websiteConsentLabelKey,
|
|
39
|
+
websiteConsentPurposeLabelKey,
|
|
40
|
+
} from '../../../schemas/label-keys/website-consent-label-keys.schemas';
|
|
41
|
+
import {
|
|
42
|
+
WEBSITE_PROVIDER_COMPONENT_LABEL_KEYS,
|
|
43
|
+
WebsiteProviderComponentLabelSlot,
|
|
44
|
+
websiteProviderComponentLabelKey,
|
|
45
|
+
} from '../../../schemas/label-keys/website-provider-component-label-keys.schemas';
|
|
46
|
+
import type { WebsitePageRef } from '../../../schemas/refs/page-ref.schemas';
|
|
47
|
+
import { writeWebsiteVisitorConsent } from '../../consent/website-consent-storage';
|
|
48
|
+
import { WebsiteProviderComponent } from '../WebsiteProviderComponent';
|
|
49
|
+
|
|
50
|
+
const LOOKUP = 'FRONTEND_ADDRESS_LOOKUP' as ProviderCapability;
|
|
51
|
+
const SKIP_KEY = WebsiteLabelKeySchema.parse('website.chrome.layout.skip-to-main-content');
|
|
52
|
+
// Every framework-owned key resolves to itself, so the rendered states are found by the key they show.
|
|
53
|
+
const LABEL_PACK = Object.fromEntries(
|
|
54
|
+
[SKIP_KEY, ...WEBSITE_CONSENT_LABEL_KEYS, ...WEBSITE_PROVIDER_COMPONENT_LABEL_KEYS].map((key) => [key as unknown as string, key as unknown as string]),
|
|
55
|
+
);
|
|
56
|
+
const text = (key: unknown): string => key as string;
|
|
57
|
+
|
|
58
|
+
const DESIGN_TOKENS = {
|
|
59
|
+
colors: {
|
|
60
|
+
background: '#fff', foreground: '#000', primary: '#000', primaryForeground: '#fff', accent: '#000', accentForeground: '#fff',
|
|
61
|
+
surface: '#fff', surfaceForeground: '#000', border: '#ccc', muted: '#eee', mutedForeground: '#666',
|
|
62
|
+
},
|
|
63
|
+
radii: { none: '0', sm: '2px', md: '4px', lg: '8px', xl: '12px', full: '9999px' },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The vendor's component: renders the prop the page passed, so the test can see it arrived. */
|
|
67
|
+
function PlacesLookup({ fieldLabel }: { fieldLabel: string }): React.ReactNode {
|
|
68
|
+
return <input aria-label={fieldLabel} data-testid="places-lookup" />;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const metadata = defineProviderMetadata({ ref: 'places', packageName: '@wildo-ai/provider-places', tier: ProviderTier.CATALOGUE, origin: ENGINE_PROVIDER_ORIGIN });
|
|
72
|
+
|
|
73
|
+
/** The DATA and CODE channels for one provider declaring ONE address-lookup component. */
|
|
74
|
+
function site(input: { load: () => Promise<unknown>; consentPurposes?: ConsentPurpose[]; scripts?: Array<{ src: string }>; withComponent?: boolean }): {
|
|
75
|
+
entries: FrontendProvidersBlock;
|
|
76
|
+
modules: GeneratedWebsiteProviderModules;
|
|
77
|
+
} {
|
|
78
|
+
const descriptor = { placeIdRetention: ProviderAddressLookupPlaceIdRetention.NONE };
|
|
79
|
+
const common = {
|
|
80
|
+
metadata,
|
|
81
|
+
providerCapabilities: [LOOKUP],
|
|
82
|
+
protocols: ['FRONTEND_SDK'],
|
|
83
|
+
...(input.consentPurposes !== undefined ? { consentPurposes: input.consentPurposes } : {}),
|
|
84
|
+
...(input.scripts !== undefined ? { scripts: input.scripts } : {}),
|
|
85
|
+
};
|
|
86
|
+
const withComponent = input.withComponent ?? true;
|
|
87
|
+
return {
|
|
88
|
+
entries: { providers: { places: { ...common, ...(withComponent ? { components: { [LOOKUP]: descriptor } } : {}) } } } as unknown as FrontendProvidersBlock,
|
|
89
|
+
modules: {
|
|
90
|
+
places: {
|
|
91
|
+
...common,
|
|
92
|
+
...(withComponent ? { components: { [LOOKUP]: { ...descriptor, load: input.load } } } : {}),
|
|
93
|
+
publicConfigSchema: defineFrontendPublicConfigSchema({}),
|
|
94
|
+
},
|
|
95
|
+
} as unknown as GeneratedWebsiteProviderModules,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ContactPage(): React.ReactNode {
|
|
100
|
+
return (
|
|
101
|
+
<form>
|
|
102
|
+
<WebsiteProviderComponent capability={LOOKUP} providerRef="places" componentProps={{ fieldLabel: 'Address' }} reservedMinHeight="3rem" />
|
|
103
|
+
</form>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function EmptyPage(): React.ReactNode {
|
|
108
|
+
return <p>No form on this page</p>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function island(channels: ReturnType<typeof site>, PageComponent: () => React.ReactNode = ContactPage): React.ReactElement {
|
|
112
|
+
const Island = createWebsitePageBridgeIsland({
|
|
113
|
+
PageComponent,
|
|
114
|
+
pageRef: 'contact' as unknown as WebsitePageRef,
|
|
115
|
+
navigation: { headerComponent: () => null, footerComponent: () => null },
|
|
116
|
+
frontendProviderModules: channels.modules,
|
|
117
|
+
});
|
|
118
|
+
return (
|
|
119
|
+
<Island
|
|
120
|
+
locale={AvailableLanguage.EN}
|
|
121
|
+
defaultLocale={AvailableLanguage.EN}
|
|
122
|
+
labelPack={LABEL_PACK}
|
|
123
|
+
designTokens={DESIGN_TOKENS as never}
|
|
124
|
+
skipToMainContentLabelKey={SKIP_KEY}
|
|
125
|
+
frontendProviders={channels.entries}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SCRIPT_SRC = 'https://maps.vendor.example/places.js';
|
|
131
|
+
const injectedScripts = (): string[] => [...document.querySelectorAll('script')].map((script) => script.getAttribute('src') ?? '').filter((src) => src === SCRIPT_SRC);
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
window.localStorage.clear();
|
|
135
|
+
for (const script of document.querySelectorAll('script')) script.remove();
|
|
136
|
+
});
|
|
137
|
+
afterEach(cleanup);
|
|
138
|
+
|
|
139
|
+
describe('a provider component on the website (#1703)', () => {
|
|
140
|
+
it('ships NOTHING of the vendor in the server render — only the reserved box', async () => {
|
|
141
|
+
const load = vi.fn(async () => PlacesLookup);
|
|
142
|
+
|
|
143
|
+
const html = renderToString(island(site({ load })));
|
|
144
|
+
// The registry starts a loader on a microtask; wait one macrotask so a load begun by the render is seen.
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
146
|
+
|
|
147
|
+
expect(load).not.toHaveBeenCalled();
|
|
148
|
+
expect(html).toContain(`data-website-provider-component="${LOOKUP}"`);
|
|
149
|
+
expect(html).toContain(`data-website-provider-component-state="${ProviderComponentStatus.NONE}"`);
|
|
150
|
+
expect(html).toContain('min-height:3rem');
|
|
151
|
+
expect(html).not.toContain('places-lookup');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('has its live region in the FIRST render, before anything is announced', () => {
|
|
155
|
+
// A screen reader announces changes to a live region it already knows about; a region inserted together with its
|
|
156
|
+
// text is often read late or not at all. So the empty role="status" must exist in the server render, in the NONE
|
|
157
|
+
// state, before any refusal can be written into it (#1703 review F3).
|
|
158
|
+
const html = renderToString(island(site({ load: vi.fn(async () => PlacesLookup) })));
|
|
159
|
+
|
|
160
|
+
expect(html).toContain(`data-website-provider-component-state="${ProviderComponentStatus.NONE}"`);
|
|
161
|
+
expect(html).toMatch(/<div role="status"><\/div>/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('renders the component the provider DECLARED, with the props the page passed, in the browser', async () => {
|
|
165
|
+
const load = vi.fn(async () => PlacesLookup);
|
|
166
|
+
|
|
167
|
+
render(island(site({ load })));
|
|
168
|
+
|
|
169
|
+
expect(await screen.findByLabelText('Address')).toBeInTheDocument();
|
|
170
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('downloads nothing until the visitor agrees, says so accessibly, and loads in place once they do', async () => {
|
|
174
|
+
const load = vi.fn(async () => PlacesLookup);
|
|
175
|
+
|
|
176
|
+
render(island(site({ load, consentPurposes: [ConsentPurpose.MARKETING] })));
|
|
177
|
+
// The visitor declines everything in the panel the layout shows.
|
|
178
|
+
fireEvent.click(await screen.findByText(text(websiteConsentLabelKey(WebsiteConsentLabelSlot.DECLINE_ALL))));
|
|
179
|
+
|
|
180
|
+
const refusal = await screen.findByText(text(websiteProviderComponentLabelKey(WebsiteProviderComponentLabelSlot.CONSENT_REQUIRED)));
|
|
181
|
+
expect(refusal.closest('[role="status"]')).not.toBeNull();
|
|
182
|
+
expect(screen.getByText(text(websiteConsentPurposeLabelKey(ConsentPurpose.MARKETING)))).toBeInTheDocument();
|
|
183
|
+
expect(load).not.toHaveBeenCalled();
|
|
184
|
+
|
|
185
|
+
// The refusal's own button opens the panel; granting there loads the component without a re-mount.
|
|
186
|
+
const manage = screen.getAllByRole('button', { name: text(websiteConsentLabelKey(WebsiteConsentLabelSlot.MANAGE)) });
|
|
187
|
+
fireEvent.click(manage[0]!);
|
|
188
|
+
fireEvent.click(await screen.findByText(text(websiteConsentLabelKey(WebsiteConsentLabelSlot.ACCEPT_ALL))));
|
|
189
|
+
|
|
190
|
+
expect(await screen.findByLabelText('Address')).toBeInTheDocument();
|
|
191
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('loads at once for a visitor who agreed on an earlier page', async () => {
|
|
195
|
+
writeWebsiteVisitorConsent([ConsentPurpose.MARKETING]);
|
|
196
|
+
const load = vi.fn(async () => PlacesLookup);
|
|
197
|
+
|
|
198
|
+
render(island(site({ load, consentPurposes: [ConsentPurpose.MARKETING] })));
|
|
199
|
+
|
|
200
|
+
expect(await screen.findByLabelText('Address')).toBeInTheDocument();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('says the feature is unavailable when the component cannot load — never a blank', async () => {
|
|
204
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
205
|
+
render(island(site({ load: async () => { throw new Error('vendor CDN unreachable'); } })));
|
|
206
|
+
|
|
207
|
+
const statement = await screen.findByText(text(websiteProviderComponentLabelKey(WebsiteProviderComponentLabelSlot.UNAVAILABLE)));
|
|
208
|
+
expect(statement.closest('[role="status"]')).not.toBeNull();
|
|
209
|
+
await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining('LOAD_FAILED')));
|
|
210
|
+
warn.mockRestore();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('says nothing, rather than "try again later", for a provider that offers no component here', async () => {
|
|
214
|
+
render(island(site({ load: async () => PlacesLookup, withComponent: false })));
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
216
|
+
expect(screen.queryByText(text(websiteProviderComponentLabelKey(WebsiteProviderComponentLabelSlot.UNAVAILABLE)))).toBeNull();
|
|
217
|
+
expect(document.querySelector(`[data-website-provider-component-state="${ProviderComponentStatus.NONE}"]`)).not.toBeNull();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('a component-bearing provider\'s scripts load WITH its component, not on every page (#1703)', () => {
|
|
222
|
+
it('injects nothing at startup on a page without the component', async () => {
|
|
223
|
+
render(island(site({ load: async () => PlacesLookup, scripts: [{ src: SCRIPT_SRC }] }), EmptyPage));
|
|
224
|
+
await screen.findByText('No form on this page');
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
226
|
+
expect(injectedScripts()).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('loads the script when the page shows the component', async () => {
|
|
230
|
+
render(island(site({ load: async () => PlacesLookup, scripts: [{ src: SCRIPT_SRC }] })));
|
|
231
|
+
await waitFor(() => expect(injectedScripts()).toEqual([SCRIPT_SRC]));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('still injects at startup the scripts of a provider that contributes NO component', async () => {
|
|
235
|
+
render(island(site({ load: async () => PlacesLookup, scripts: [{ src: SCRIPT_SRC }], withComponent: false }), EmptyPage));
|
|
236
|
+
await waitFor(() => expect(injectedScripts()).toEqual([SCRIPT_SRC]));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1704 — the website's consent-based providers start on the visitor's grant, stop on withdrawal, and a start never
|
|
3
|
+
* overlaps the previous stop of the same page-global vendor.
|
|
4
|
+
*
|
|
5
|
+
* The runner and the lane are the real ones; only the vendor is a double.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
10
|
+
import { BUILTIN_PROVIDER_CAPABILITY, ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
|
|
11
|
+
import type { FrontendWebsiteProviderRegistry } from '@wildo-ai/external-connectors-public/website';
|
|
12
|
+
|
|
13
|
+
import { useWebsiteProviderSdks } from '../useWebsiteProviderScripts';
|
|
14
|
+
|
|
15
|
+
afterEach(() => { cleanup(); });
|
|
16
|
+
|
|
17
|
+
function Harness({ registry, granted }: { registry: FrontendWebsiteProviderRegistry; granted: ConsentPurpose[] }) {
|
|
18
|
+
useWebsiteProviderSdks(registry, granted);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function registryOf(activate: () => Promise<unknown>): FrontendWebsiteProviderRegistry {
|
|
23
|
+
const module = {
|
|
24
|
+
metadata: { ref: 'posthog' },
|
|
25
|
+
providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS],
|
|
26
|
+
protocols: ['FRONTEND_SDK'],
|
|
27
|
+
publicConfig: {},
|
|
28
|
+
sdk: { descriptor: { packageName: '@vendor/posthog' }, stoppable: true, activate },
|
|
29
|
+
};
|
|
30
|
+
return { modules: new Map([['posthog', module]]) } as unknown as FrontendWebsiteProviderRegistry;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('useWebsiteProviderSdks — the consent-based group (#1704)', () => {
|
|
34
|
+
it('starts nothing before the grant, starts on it and stops on withdrawal', async () => {
|
|
35
|
+
const deactivate = vi.fn();
|
|
36
|
+
const activate = vi.fn(async () => ({ handle: {}, deactivate }));
|
|
37
|
+
const registry = registryOf(activate);
|
|
38
|
+
const view = render(<Harness registry={registry} granted={[]} />);
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
40
|
+
expect(activate).not.toHaveBeenCalled();
|
|
41
|
+
|
|
42
|
+
view.rerender(<Harness registry={registry} granted={[ConsentPurpose.ANALYTICS]} />);
|
|
43
|
+
await waitFor(() => expect(activate).toHaveBeenCalledTimes(1));
|
|
44
|
+
view.rerender(<Harness registry={registry} granted={[]} />);
|
|
45
|
+
await waitFor(() => expect(deactivate).toHaveBeenCalledTimes(1));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('does not start again until the previous start has fully stopped (grant, withdraw, grant while the vendor loads)', async () => {
|
|
49
|
+
const order: string[] = [];
|
|
50
|
+
let releaseFirst!: () => void;
|
|
51
|
+
const firstLoaded = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
|
52
|
+
let starts = 0;
|
|
53
|
+
const activate = vi.fn(async () => {
|
|
54
|
+
starts += 1;
|
|
55
|
+
const n = starts;
|
|
56
|
+
order.push(`start:${n}`);
|
|
57
|
+
if (n === 1) await firstLoaded;
|
|
58
|
+
return { handle: {}, deactivate: () => { order.push(`stop:${n}`); } };
|
|
59
|
+
});
|
|
60
|
+
const registry = registryOf(activate);
|
|
61
|
+
const view = render(<Harness registry={registry} granted={[ConsentPurpose.ANALYTICS]} />);
|
|
62
|
+
await waitFor(() => expect(order).toEqual(['start:1']));
|
|
63
|
+
|
|
64
|
+
view.rerender(<Harness registry={registry} granted={[]} />);
|
|
65
|
+
view.rerender(<Harness registry={registry} granted={[ConsentPurpose.ANALYTICS]} />);
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
67
|
+
expect(order).toEqual(['start:1']);
|
|
68
|
+
|
|
69
|
+
releaseFirst();
|
|
70
|
+
await waitFor(() => expect(order).toEqual(['start:1', 'stop:1', 'start:2']));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The website's provider-component registry (#1703): the SAME registry the application builds, from
|
|
3
|
+
* the SAME `components` declaration, over the website's live provider modules.
|
|
4
|
+
*
|
|
5
|
+
* A provider declares its components once, on its frontend module. Before this, the website facet
|
|
6
|
+
* type accepted `components` and nothing on the website read it, so a contact form could not host an
|
|
7
|
+
* address lookup (#561) and a marketing page could not host any provider component at all. The
|
|
8
|
+
* registry is `createProviderComponentRegistry` from `@wildo-ai/external-connectors-public/website`
|
|
9
|
+
* — keyed `capability:providerRef`, consent-gated, script-before-component and browser-only — and
|
|
10
|
+
* this file only feeds it the website's two inputs:
|
|
11
|
+
*
|
|
12
|
+
* - the HYDRATED website provider registry (both channels joined, so a provider not live for this
|
|
13
|
+
* website service contributes no component);
|
|
14
|
+
* - the loaders the site's facet library contributes for components an engine provider module
|
|
15
|
+
* declares but cannot import (the module lives in a renderer-free package), handed in through the
|
|
16
|
+
* page bridge's `providerComponentLoaders` rather than through module-level state, because the
|
|
17
|
+
* bridge is the website's one construction site and can carry it explicitly.
|
|
18
|
+
*
|
|
19
|
+
* Nothing is loaded here. A component loads when a `WebsiteProviderComponent` slot asks for it, from
|
|
20
|
+
* an effect — never during the Astro server render.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useMemo, type ComponentType } from 'react';
|
|
24
|
+
import {
|
|
25
|
+
collectProviderComponentRegistrations,
|
|
26
|
+
createProviderComponentRegistry,
|
|
27
|
+
indexProviderComponentLoaderContributions,
|
|
28
|
+
type ProviderComponentDiagnostic,
|
|
29
|
+
type ProviderComponentLoaderContribution,
|
|
30
|
+
type ProviderComponentRegistry,
|
|
31
|
+
} from '@wildo-ai/external-connectors-public/website';
|
|
32
|
+
|
|
33
|
+
import type { FrontendProvidersBlock } from '@wildo-ai/saas-models/public-runtime';
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
createFrontendWebsiteProviderRegistry,
|
|
37
|
+
type FrontendWebsiteProviderRegistry,
|
|
38
|
+
type GeneratedWebsiteProviderModules,
|
|
39
|
+
} from './frontend-provider-registry.website';
|
|
40
|
+
|
|
41
|
+
/** A component type the website's registry hands back once loaded. */
|
|
42
|
+
export type WebsiteProviderComponentType = ComponentType<never>;
|
|
43
|
+
|
|
44
|
+
/** The website's provider-component registry: the shared registry, holding React components. */
|
|
45
|
+
export type WebsiteProviderComponentRegistry = ProviderComponentRegistry<WebsiteProviderComponentType>;
|
|
46
|
+
|
|
47
|
+
export interface CreateWebsiteProviderComponentRegistryInput {
|
|
48
|
+
/** The hydrated website provider registry; absent on a site with no providers. */
|
|
49
|
+
readonly providerRegistry: FrontendWebsiteProviderRegistry | undefined;
|
|
50
|
+
/** Loaders the site's facet library contributes for engine-declared components. */
|
|
51
|
+
readonly providerComponentLoaders?: Iterable<ProviderComponentLoaderContribution>;
|
|
52
|
+
/**
|
|
53
|
+
* Where a registration or load problem is reported. Defaults to `console.warn`: the website runtime
|
|
54
|
+
* has no logger service, and a provider component that cannot be offered is a developer-facing fact
|
|
55
|
+
* that must not be silent.
|
|
56
|
+
*/
|
|
57
|
+
readonly onDiagnostic?: (diagnostic: ProviderComponentDiagnostic) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function warnProviderComponentDiagnostic(diagnostic: ProviderComponentDiagnostic): void {
|
|
61
|
+
// Reported in the BROWSER only. The registry is built during the server render or prerender too, once per
|
|
62
|
+
// page, and a declaration nothing can load would otherwise fill the build log with one warning per page
|
|
63
|
+
// for a fact the first page already stated. The browser is where somebody can see and act on it.
|
|
64
|
+
if (typeof document === 'undefined') return;
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.warn(`[wildo-provider-component] ${diagnostic.kind}: ${diagnostic.message}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Builds the website's provider-component registry from its live provider modules. */
|
|
70
|
+
export function createWebsiteProviderComponentRegistry(
|
|
71
|
+
input: CreateWebsiteProviderComponentRegistryInput,
|
|
72
|
+
): WebsiteProviderComponentRegistry {
|
|
73
|
+
const onDiagnostic = input.onDiagnostic ?? warnProviderComponentDiagnostic;
|
|
74
|
+
const registrations = collectProviderComponentRegistrations(input.providerRegistry?.modules.values() ?? [], {
|
|
75
|
+
contributedLoaders: indexProviderComponentLoaderContributions(input.providerComponentLoaders),
|
|
76
|
+
onDiagnostic,
|
|
77
|
+
});
|
|
78
|
+
return createProviderComponentRegistry<WebsiteProviderComponentType>({ registrations, onDiagnostic });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What a page bridge builds once per island: the hydrated provider registry and its component registry. */
|
|
82
|
+
export interface WebsiteProviderRegistries {
|
|
83
|
+
readonly frontendProviderRegistry: FrontendWebsiteProviderRegistry;
|
|
84
|
+
readonly providerComponentRegistry: WebsiteProviderComponentRegistry;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Builds both registries for a page bridge, MEMOISED on their inputs.
|
|
89
|
+
*
|
|
90
|
+
* Memoised because the component registry caches each loaded component: a registry rebuilt on every
|
|
91
|
+
* render would forget what it loaded, hand every slot a new registry identity, and make each slot's
|
|
92
|
+
* load effect run again — a download loop. The inputs are an island prop (`frontendProviders`, fixed
|
|
93
|
+
* for the island's life) and two module-load-time bridge dependencies, so in practice this builds
|
|
94
|
+
* once per island.
|
|
95
|
+
*/
|
|
96
|
+
export function useWebsiteProviderRegistries(input: {
|
|
97
|
+
readonly frontendProviders: FrontendProvidersBlock | undefined;
|
|
98
|
+
readonly frontendProviderModules: GeneratedWebsiteProviderModules | undefined;
|
|
99
|
+
readonly providerComponentLoaders: Iterable<ProviderComponentLoaderContribution> | undefined;
|
|
100
|
+
}): WebsiteProviderRegistries {
|
|
101
|
+
const { frontendProviders, frontendProviderModules, providerComponentLoaders } = input;
|
|
102
|
+
return useMemo(() => {
|
|
103
|
+
const frontendProviderRegistry = createFrontendWebsiteProviderRegistry({
|
|
104
|
+
entries: frontendProviders,
|
|
105
|
+
modules: frontendProviderModules,
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
frontendProviderRegistry,
|
|
109
|
+
providerComponentRegistry: createWebsiteProviderComponentRegistry({
|
|
110
|
+
providerRegistry: frontendProviderRegistry,
|
|
111
|
+
...(providerComponentLoaders !== undefined ? { providerComponentLoaders } : {}),
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}, [frontendProviders, frontendProviderModules, providerComponentLoaders]);
|
|
115
|
+
}
|