@wildo-ai/saas-website 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/esm/astro/blog-post-bridge.d.ts +14 -20
  2. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  3. package/dist/esm/astro/blog-post-bridge.js +4 -2
  4. package/dist/esm/astro/blog-post-bridge.js.map +1 -1
  5. package/dist/esm/astro/bridge-runtime.d.ts +30 -20
  6. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  7. package/dist/esm/astro/bridge-runtime.js +13 -3
  8. package/dist/esm/astro/bridge-runtime.js.map +1 -1
  9. package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
  10. package/dist/esm/astro/label-pack-loader.js +5 -0
  11. package/dist/esm/astro/label-pack-loader.js.map +1 -1
  12. package/dist/esm/config/load-website-config.d.ts.map +1 -1
  13. package/dist/esm/config/load-website-config.js +2 -1
  14. package/dist/esm/config/load-website-config.js.map +1 -1
  15. package/dist/esm/config/wildo-website-config.schemas.d.ts +10 -5
  16. package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
  17. package/dist/esm/config/wildo-website-config.schemas.js +10 -5
  18. package/dist/esm/config/wildo-website-config.schemas.js.map +1 -1
  19. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +3 -3
  20. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
  21. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +13 -9
  22. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  23. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +11 -0
  24. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
  25. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +14 -1
  26. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
  27. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts +6 -0
  28. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -0
  29. package/dist/esm/core/consent/WebsiteConsentBanner.js +128 -0
  30. package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -0
  31. package/dist/esm/core/consent/WebsiteConsentContext.d.ts +85 -0
  32. package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -0
  33. package/dist/esm/core/consent/WebsiteConsentContext.js +89 -0
  34. package/dist/esm/core/consent/WebsiteConsentContext.js.map +1 -0
  35. package/dist/esm/core/consent/WebsiteConsentGate.d.ts +27 -0
  36. package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -0
  37. package/dist/esm/core/consent/WebsiteConsentGate.js +9 -0
  38. package/dist/esm/core/consent/WebsiteConsentGate.js.map +1 -0
  39. package/dist/esm/core/consent/useWebsiteConsent.d.ts +10 -0
  40. package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -0
  41. package/dist/esm/core/consent/useWebsiteConsent.js +17 -0
  42. package/dist/esm/core/consent/useWebsiteConsent.js.map +1 -0
  43. package/dist/esm/core/consent/website-consent-storage.d.ts +22 -0
  44. package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -0
  45. package/dist/esm/core/consent/website-consent-storage.js +102 -0
  46. package/dist/esm/core/consent/website-consent-storage.js.map +1 -0
  47. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +8 -1
  48. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  49. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  50. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +1 -2
  51. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  52. package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -1
  53. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +33 -10
  54. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
  55. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +64 -16
  56. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
  57. package/dist/esm/core/layouts/WebsitePageLayout.d.ts +21 -1
  58. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  59. package/dist/esm/core/layouts/WebsitePageLayout.js +32 -4
  60. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  61. package/dist/esm/index.d.ts +6 -0
  62. package/dist/esm/index.d.ts.map +1 -1
  63. package/dist/esm/index.js +6 -0
  64. package/dist/esm/index.js.map +1 -1
  65. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts +61 -0
  66. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -0
  67. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js +73 -0
  68. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js.map +1 -0
  69. package/dist/tsconfig.build.tsbuildinfo +1 -1
  70. package/package.json +6 -29
  71. package/src/astro/__tests__/blog-post-bridge.test.tsx +41 -0
  72. package/src/astro/__tests__/bridge-runtime.test.tsx +25 -0
  73. package/src/astro/__tests__/label-pack-loader.test.ts +26 -1
  74. package/src/astro/blog-post-bridge.tsx +18 -22
  75. package/src/astro/bridge-runtime.tsx +45 -23
  76. package/src/astro/label-pack-loader.ts +4 -0
  77. package/src/config/load-website-config.ts +2 -1
  78. package/src/config/wildo-website-config.schemas.ts +10 -5
  79. package/src/core/__tests__/WebsitePageLayout.test.tsx +1 -1
  80. package/src/core/__tests__/website-consent.test.tsx +311 -0
  81. package/src/core/anonymous-session/__tests__/inbound-contact-form.schema.test.ts +11 -4
  82. package/src/core/anonymous-session/inbound-contact-form.schema.ts +14 -9
  83. package/src/core/anonymous-session/website-anonymous-session-client.ts +15 -1
  84. package/src/core/consent/WebsiteConsentBanner.tsx +222 -0
  85. package/src/core/consent/WebsiteConsentContext.tsx +164 -0
  86. package/src/core/consent/WebsiteConsentGate.tsx +34 -0
  87. package/src/core/consent/useWebsiteConsent.ts +18 -0
  88. package/src/core/consent/website-consent-storage.ts +116 -0
  89. package/src/core/contexts/WebsiteRuntimeContext.tsx +8 -1
  90. package/src/core/external-providers/frontend-provider-registry.website.ts +1 -2
  91. package/src/core/external-providers/useWebsiteProviderScripts.ts +72 -17
  92. package/src/core/layouts/WebsitePageLayout.tsx +66 -3
  93. package/src/index.ts +7 -0
  94. package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +71 -0
  95. package/src/schemas/label-keys/website-consent-label-keys.schemas.ts +78 -0
  96. package/dist/esm/core/external-providers/provider-scripts.website.d.ts +0 -68
  97. package/dist/esm/core/external-providers/provider-scripts.website.d.ts.map +0 -1
  98. package/dist/esm/core/external-providers/provider-scripts.website.js +0 -99
  99. package/dist/esm/core/external-providers/provider-scripts.website.js.map +0 -1
  100. package/src/core/external-providers/__tests__/provider-scripts.website.test.ts +0 -146
  101. 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,4 +1,4 @@
1
- import React, { createContext, type ReactNode } from 'react';
1
+ import React, { createContext, type ComponentType, type ReactNode } from 'react';
2
2
 
3
3
  import type { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
4
4
 
@@ -152,6 +152,13 @@ export interface WebsiteRuntimeContextValue {
152
152
  * catalog for analytics / captcha / browser-monitoring slices.
153
153
  */
154
154
  frontendProviderRegistry?: FrontendWebsiteProviderRegistry;
155
+ /**
156
+ * Replaces the framework's visitor consent panel (#534). Reads `useWebsiteConsent()`; see
157
+ * `WebsiteConsentBanner` for the three properties a replacement must keep. Absent uses the default.
158
+ * Closed over by the bridge rather than passed as an island prop, like `navigation`, because a
159
+ * component cannot cross Astro's serialization boundary.
160
+ */
161
+ consentBannerComponent?: ComponentType;
155
162
  /**
156
163
  * Backend API origin (WITHOUT a trailing slash) that anonymous-capture
157
164
  * sections POST to when constructing a `WebsiteAnonymousSessionClient`
@@ -40,8 +40,7 @@ export interface CreateFrontendWebsiteProviderRegistryInput {
40
40
  readonly entries?: FrontendProvidersBlock;
41
41
  /**
42
42
  * The CODE channel. Absent is the ordinary case for a website that declares
43
- * no package-shipped provider — `wildo config sync` writes no generated file,
44
- * so there is nothing to import and nothing to pass.
43
+ * no providers. Config sync still writes an empty map so startup imports remain stable.
45
44
  */
46
45
  readonly modules?: GeneratedWebsiteProviderModules;
47
46
  readonly onDiagnostic?: (diagnostic: FrontendProviderHydrationDiagnostic) => void;
@@ -12,35 +12,58 @@
12
12
  * does not exist would throw, and starting a vendor SDK there would run a live
13
13
  * analytics client inside a build.
14
14
  *
15
- * SDK activation comes from `@wildo-ai/external-connectors-public/website`, which
16
- * this package now depends on. The script injector beside it does NOT yet, and
17
- * that is a timing artifact rather than a decision: it belongs in the same
18
- * package so the technical doc can use one copy — a second copy of a DOM
19
- * mutation eventually disagrees about idempotence, which is how a vendor tag
20
- * gets loaded twice. Moving it needs that package's `dist` to carry it, and no
21
- * watcher is running in this tree; consolidating it is a one-step follow-up, not
22
- * a design question.
15
+ * Both come from `@wildo-ai/external-connectors-public/website`. The script
16
+ * injector lives there rather than here so the SaaS application, the technical
17
+ * doc and this site run ONE copy of it: a second copy of a DOM mutation
18
+ * eventually disagrees about idempotence, which is how a vendor tag gets loaded
19
+ * twice. It first shipped here alone, and that is how the application came to
20
+ * declare its Drive Picker's Google API script and never inject it.
21
+ *
22
+ * ## Client-side injection, and the alternative that was rejected
23
+ *
24
+ * This is an Astro static site, so the "obvious" home for a third-party tag is
25
+ * the built HTML `<head>`. That was rejected for now: the set of live providers
26
+ * is per SERVICE and per ENVIRONMENT, resolved from the materialized artifact,
27
+ * while the built HTML is one artifact shared across deploys of the same build.
28
+ * Baking the tags in would make the build environment-specific — the same class
29
+ * of coupling the DATA channel exists to avoid. Injecting from the client keeps
30
+ * one build correct everywhere, at the cost of the tag arriving after hydration.
31
+ * Revisit if a provider ever needs to run before first paint.
23
32
  */
24
33
 
25
34
  import { useEffect } from 'react';
26
35
  import {
27
36
  activateFrontendProviderSdks,
28
- type ActivatedFrontendProviderSdks,
29
- } from '@wildo-ai/external-connectors-public/website';
30
- import {
31
37
  collectDeclaredProviderScripts,
38
+ consentPurposesRequiredByProvider,
32
39
  injectProviderScripts,
33
- } from './provider-scripts.website';
34
- import { AppFrontendType } from '@wildo-ai/saas-models/public-runtime';
40
+ type ActivatedFrontendProviderSdks,
41
+ } from '@wildo-ai/external-connectors-public/website';
42
+ import { AppFrontendType, type ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
35
43
  import type { FrontendWebsiteProviderRegistry } from './frontend-provider-registry.website';
36
44
 
45
+ /**
46
+ * A stable key for a granted-purpose set, so an effect re-runs when the SET changes and not when a new
47
+ * array with the same members is handed in.
48
+ */
49
+ function grantedKey(grantedConsentPurposes: readonly ConsentPurpose[]): string {
50
+ return [...grantedConsentPurposes].sort().join(',');
51
+ }
52
+
37
53
  export function useWebsiteProviderScripts(
38
54
  registry: FrontendWebsiteProviderRegistry | undefined,
55
+ /**
56
+ * The purposes the visitor granted (#534). A provider resting on an ungranted purpose injects nothing;
57
+ * granting it later injects its script then. A withdrawal cannot remove an executed script, so it takes
58
+ * effect on the next page load.
59
+ */
60
+ grantedConsentPurposes: readonly ConsentPurpose[],
39
61
  ): void {
62
+ const key = grantedKey(grantedConsentPurposes);
40
63
  useEffect(() => {
41
64
  if (typeof document === 'undefined') return;
42
65
 
43
- const scripts = collectDeclaredProviderScripts(registry);
66
+ const scripts = collectDeclaredProviderScripts(registry?.modules.values(), grantedConsentPurposes);
44
67
  if (scripts.length === 0) return;
45
68
 
46
69
  injectProviderScripts(scripts, document);
@@ -48,7 +71,8 @@ export function useWebsiteProviderScripts(
48
71
  // work immediately; removing the element undoes neither, so a cleanup that
49
72
  // removed it would only make the next mount inject a SECOND copy of an
50
73
  // already-running script. Idempotence by `src` is the real guard.
51
- }, [registry]);
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by the granted SET, see grantedKey.
75
+ }, [registry, key]);
52
76
  }
53
77
 
54
78
  /**
@@ -60,16 +84,46 @@ export function useWebsiteProviderScripts(
60
84
  */
61
85
  export function useWebsiteProviderSdks(
62
86
  registry: FrontendWebsiteProviderRegistry | undefined,
87
+ /**
88
+ * The purposes the visitor granted (#534). Providers are activated in TWO groups on purpose: those that
89
+ * rest on no purpose (error monitoring, captcha) are started once and never restarted by a consent
90
+ * change, while the consent-based ones start when their purpose is granted and are DEACTIVATED when it
91
+ * is withdrawn — which is the only way a withdrawal stops a running analytics client.
92
+ */
93
+ grantedConsentPurposes: readonly ConsentPurpose[],
94
+ ): void {
95
+ useProviderSdkGroup(registry, false, []);
96
+ useProviderSdkGroup(registry, true, grantedConsentPurposes);
97
+ }
98
+
99
+ function useProviderSdkGroup(
100
+ registry: FrontendWebsiteProviderRegistry | undefined,
101
+ consentBased: boolean,
102
+ grantedConsentPurposes: readonly ConsentPurpose[],
63
103
  ): void {
104
+ // Keyed by the grants this group's providers actually rest on, so changing an unrelated purpose does
105
+ // not stop and restart a running analytics client.
106
+ const relevant = registry === undefined
107
+ ? []
108
+ : [...registry.modules.values()]
109
+ .filter((module) => (consentPurposesRequiredByProvider(module).length > 0) === consentBased)
110
+ .flatMap((module) => consentPurposesRequiredByProvider(module))
111
+ .filter((purpose) => grantedConsentPurposes.includes(purpose));
112
+ const key = grantedKey(relevant);
64
113
  useEffect(() => {
65
114
  if (registry === undefined) return;
115
+ const modules = [...registry.modules.values()].filter(
116
+ (module) => (consentPurposesRequiredByProvider(module).length > 0) === consentBased,
117
+ );
118
+ if (modules.length === 0) return;
66
119
 
67
120
  let unmounted = false;
68
121
  let activated: ActivatedFrontendProviderSdks | undefined;
69
122
 
70
123
  void activateFrontendProviderSdks({
71
124
  surface: AppFrontendType.STATIC_WEBSITE,
72
- modules: registry.modules.values(),
125
+ modules,
126
+ grantedConsentPurposes,
73
127
  onDiagnostic: (diagnostic) => {
74
128
  // The website runtime has no logger service, and a provider that cannot
75
129
  // start is a developer-facing fact that must not be silent.
@@ -88,5 +142,6 @@ export function useWebsiteProviderSdks(
88
142
  unmounted = true;
89
143
  void activated?.deactivateAll();
90
144
  };
91
- }, [registry]);
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by the granted SET, see grantedKey.
146
+ }, [registry, consentBased, key]);
92
147
  }
@@ -1,5 +1,6 @@
1
1
  import React, { useMemo, type ReactNode } from 'react';
2
2
  import { BUILTIN_PROVIDER_CAPABILITY } from '@wildo-ai/saas-models/public-runtime';
3
+ import { consentPurposesInUseByProviders } from '@wildo-ai/external-connectors-public/website';
3
4
 
4
5
  import { WebsitePageContextProvider } from '../contexts/WebsitePageContext';
5
6
  import {
@@ -9,6 +10,9 @@ import {
9
10
  import type { WebsitePageContextValue } from '../contexts/WebsitePageContext';
10
11
  import { useWebsiteRuntime } from '../contexts/useWebsiteRuntime';
11
12
  import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';
13
+ import { WebsiteConsentProvider, WebsiteConsentStatus, type WebsiteConsentRecorder } from '../consent/WebsiteConsentContext';
14
+ import { useWebsiteConsent } from '../consent/useWebsiteConsent';
15
+ import { WebsiteConsentBanner, WebsiteConsentSettingsButton } from '../consent/WebsiteConsentBanner';
12
16
  import {
13
17
  WebsitePageRefSchema,
14
18
  type WebsitePageRef,
@@ -133,6 +137,14 @@ const SKIP_LINK_FOCUS_CSS = `
133
137
  * captcha so non-React tooling can detect the configured vendor
134
138
  * without reverse-engineering app code.
135
139
  *
140
+ * 4. **Provides the visitor's consent state and enforces it** (#534) —
141
+ * mounts `WebsiteConsentProvider`, starts a provider SDK or script only
142
+ * once the purpose it rests on is granted (and stops the SDK when it is
143
+ * withdrawn), and renders the consent panel when the site runs a
144
+ * consent-based provider and the visitor has not decided. Here rather
145
+ * than in each bridge because every page — including every blog post —
146
+ * renders through this layout, so no page can be mounted without it.
147
+ *
136
148
  * **Why semantic `<header>` / `<main>` / `<footer>` elements**:
137
149
  * marketing-site SEO scoring penalizes pages that ship a single
138
150
  * unstructured `<div>` tree. Using semantic landmarks costs nothing
@@ -178,6 +190,17 @@ export interface WebsitePageLayoutProps {
178
190
  * vertical-rhythm overrides on long-scroll landing pages.
179
191
  */
180
192
  mainClassName?: string;
193
+ /**
194
+ * OPTIONAL anonymous-session client. When supplied, a visitor's consent decision is ALSO recorded
195
+ * server-side against their session, which is what lets it follow them into the account they go on
196
+ * to create (#1258). Pass the SAME instance the page's capture sections use, so one `uuidRef`
197
+ * carries the decision, the lead and the draft note together.
198
+ *
199
+ * Absent keeps today's behaviour exactly: the decision lives in this browser and nowhere else.
200
+ * The engine cannot construct the client here — it needs the application's own
201
+ * `frontendServiceName` — which is why it arrives as a prop rather than off the runtime context.
202
+ */
203
+ consentRecorder?: WebsiteConsentRecorder;
181
204
  /**
182
205
  * The page's section composition. Typically a sequence of
183
206
  * `<WebsiteSection>` wrappers around concrete section components.
@@ -185,7 +208,21 @@ export interface WebsitePageLayoutProps {
185
208
  children: ReactNode;
186
209
  }
187
210
 
188
- export function WebsitePageLayout({
211
+ export function WebsitePageLayout(props: WebsitePageLayoutProps): ReactNode {
212
+ const runtime = useWebsiteRuntime();
213
+ // Derived from the LIVE providers, so the panel asks about exactly what this site processes.
214
+ const purposesInUse = useMemo(
215
+ () => consentPurposesInUseByProviders(runtime.frontendProviderRegistry?.modules.values()),
216
+ [runtime.frontendProviderRegistry],
217
+ );
218
+ return (
219
+ <WebsiteConsentProvider purposesInUse={purposesInUse} recorder={props.consentRecorder}>
220
+ <WebsitePageLayoutContent {...props} />
221
+ </WebsiteConsentProvider>
222
+ );
223
+ }
224
+
225
+ function WebsitePageLayoutContent({
189
226
  pageRef,
190
227
  header,
191
228
  footer,
@@ -194,6 +231,7 @@ export function WebsitePageLayout({
194
231
  children,
195
232
  }: WebsitePageLayoutProps): ReactNode {
196
233
  const runtime = useWebsiteRuntime();
234
+ const consent = useWebsiteConsent();
197
235
 
198
236
  /**
199
237
  * Defensive parse: callers should pass a `WebsitePageManifest.ref`
@@ -240,8 +278,11 @@ export function WebsitePageLayout({
240
278
  // resolved provider refs and stamped them into the attributes below and
241
279
  // stopped there — a provider could be declared, enabled and hydrated without
242
280
  // anything of it ever running.
243
- useWebsiteProviderScripts(runtime.frontendProviderRegistry);
244
- useWebsiteProviderSdks(runtime.frontendProviderRegistry);
281
+ //
282
+ // Consent-based providers start only once their purpose is granted (#534). Before the stored decision is
283
+ // read, nothing is granted — the static HTML and the first render start nothing optional.
284
+ useWebsiteProviderScripts(runtime.frontendProviderRegistry, consent.grantedPurposes);
285
+ useWebsiteProviderSdks(runtime.frontendProviderRegistry, consent.grantedPurposes);
245
286
 
246
287
  const providerRootAttributes = useMemo<Record<string, string>>(() => {
247
288
  const attrs: Record<string, string> = {};
@@ -314,9 +355,31 @@ export function WebsitePageLayout({
314
355
  {children}
315
356
  </main>
316
357
  {resolvedFooter !== null ? <footer>{resolvedFooter}</footer> : null}
358
+ {renderConsentSurface(consent, runtime.consentBannerComponent)}
317
359
  </div>
318
360
  </WebsitePageContextProvider>
319
361
  );
320
362
  }
321
363
  WebsitePageLayout.displayName = 'WebsitePageLayout';
364
+
365
+ /**
366
+ * The panel while a decision is owed (or re-opened), the reopen button once one is made, and nothing at
367
+ * all on a site whose live providers rest on no consent. Nothing renders before the stored decision is
368
+ * read, so a visitor who already decided never sees the panel flash.
369
+ */
370
+ function renderConsentSurface(
371
+ consent: ReturnType<typeof useWebsiteConsent>,
372
+ BannerOverride: React.ComponentType | undefined,
373
+ ): ReactNode {
374
+ if (!consent.isResolved || consent.purposesInUse.length === 0) return null;
375
+ if (consent.status === WebsiteConsentStatus.UNDECIDED || consent.isSettingsOpen) {
376
+ const Banner = BannerOverride ?? WebsiteConsentBanner;
377
+ return <Banner />;
378
+ }
379
+ return (
380
+ <div data-website-consent-settings="">
381
+ <WebsiteConsentSettingsButton />
382
+ </div>
383
+ );
384
+ }
322
385
  /** @wildo_source:part:end saas.website.page-layout.component */
package/src/index.ts CHANGED
@@ -95,6 +95,7 @@
95
95
  export * from './schemas/refs/page-ref.schemas';
96
96
  export * from './schemas/refs/section-ref.schemas';
97
97
  export * from './schemas/label-keys/website-label-key.schemas';
98
+ export * from './schemas/label-keys/website-consent-label-keys.schemas';
98
99
  export * from './schemas/sections/website-section-category.shared';
99
100
  export * from './schemas/sections/website-section-definition.shared.schemas';
100
101
  export * from './schemas/structured-data/website-structured-data.shared.schemas';
@@ -118,6 +119,12 @@ export * from './core/factories/define-website-page-manifest';
118
119
  export * from './core/layouts/WebsitePageLayout';
119
120
  export * from './core/layouts/WebsiteSection';
120
121
 
122
+ export * from './core/consent/website-consent-storage';
123
+ export * from './core/consent/WebsiteConsentContext';
124
+ export * from './core/consent/useWebsiteConsent';
125
+ export * from './core/consent/WebsiteConsentGate';
126
+ export * from './core/consent/WebsiteConsentBanner';
127
+
121
128
  export * from './core/anonymous-session/website-anonymous-session-client';
122
129
  export * from './core/anonymous-session/inbound-contact-form.schema';
123
130
  export * from './core/anonymous-session/InboundContactForm';