@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.
- package/dist/esm/astro/blog-post-bridge.d.ts +14 -20
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +4 -2
- package/dist/esm/astro/blog-post-bridge.js.map +1 -1
- package/dist/esm/astro/bridge-runtime.d.ts +30 -20
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +13 -3
- package/dist/esm/astro/bridge-runtime.js.map +1 -1
- package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.js +5 -0
- package/dist/esm/astro/label-pack-loader.js.map +1 -1
- package/dist/esm/config/load-website-config.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.js +2 -1
- package/dist/esm/config/load-website-config.js.map +1 -1
- 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/core/anonymous-session/inbound-contact-form.schema.d.ts +3 -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 +13 -9
- 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 +11 -0
- 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 +14 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
- 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 +128 -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/WebsiteRuntimeContext.d.ts +8 -1
- 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/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/useWebsiteProviderScripts.d.ts +33 -10
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +64 -16
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.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 +32 -4
- package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.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/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +6 -29
- package/src/astro/__tests__/blog-post-bridge.test.tsx +41 -0
- package/src/astro/__tests__/bridge-runtime.test.tsx +25 -0
- package/src/astro/__tests__/label-pack-loader.test.ts +26 -1
- package/src/astro/blog-post-bridge.tsx +18 -22
- package/src/astro/bridge-runtime.tsx +45 -23
- package/src/astro/label-pack-loader.ts +4 -0
- package/src/config/load-website-config.ts +2 -1
- package/src/config/wildo-website-config.schemas.ts +10 -5
- package/src/core/__tests__/WebsitePageLayout.test.tsx +1 -1
- package/src/core/__tests__/website-consent.test.tsx +311 -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 +14 -9
- package/src/core/anonymous-session/website-anonymous-session-client.ts +15 -1
- package/src/core/consent/WebsiteConsentBanner.tsx +222 -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 +8 -1
- package/src/core/external-providers/frontend-provider-registry.website.ts +1 -2
- package/src/core/external-providers/useWebsiteProviderScripts.ts +72 -17
- package/src/core/layouts/WebsitePageLayout.tsx +66 -3
- package/src/index.ts +7 -0
- package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +71 -0
- package/src/schemas/label-keys/website-consent-label-keys.schemas.ts +78 -0
- 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,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
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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';
|