@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,311 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import '@testing-library/jest-dom';
|
|
5
|
+
import {
|
|
6
|
+
ANONYMOUS_CONSENT_RECORD_METHOD,
|
|
7
|
+
ANONYMOUS_CONSENT_RECORD_PATH,
|
|
8
|
+
AvailableLanguage,
|
|
9
|
+
BUILTIN_PROVIDER_CAPABILITY,
|
|
10
|
+
ConsentPurpose,
|
|
11
|
+
defineProviderMetadata,
|
|
12
|
+
ProviderOriginKind,
|
|
13
|
+
ProviderTier,
|
|
14
|
+
WEBSITE_VISITOR_CONSENT_STORAGE_KEY,
|
|
15
|
+
} from '@wildo-ai/saas-models/public-runtime';
|
|
16
|
+
|
|
17
|
+
import { WebsitePageLayout } from '../layouts/WebsitePageLayout';
|
|
18
|
+
import { WebsiteRuntimeContextProvider, type WebsiteRuntimeContextValue } from '../contexts/WebsiteRuntimeContext';
|
|
19
|
+
import { WebsiteConsentGate } from '../consent/WebsiteConsentGate';
|
|
20
|
+
import { readWebsiteVisitorConsent, writeWebsiteVisitorConsent } from '../consent/website-consent-storage';
|
|
21
|
+
import { WebsiteLabelKeySchema } from '../../schemas/label-keys/website-label-key.schemas';
|
|
22
|
+
import {
|
|
23
|
+
WEBSITE_CONSENT_LABEL_KEYS,
|
|
24
|
+
WebsiteConsentLabelSlot,
|
|
25
|
+
websiteConsentLabelKey,
|
|
26
|
+
websiteConsentPurposeDescriptionLabelKey,
|
|
27
|
+
websiteConsentPurposeLabelKey,
|
|
28
|
+
} from '../../schemas/label-keys/website-consent-label-keys.schemas';
|
|
29
|
+
import type { FrontendWebsiteProviderRegistry } from '../external-providers/frontend-provider-registry.website';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The visitor tier of the consent gate (#534), through the real layout.
|
|
33
|
+
*
|
|
34
|
+
* What this pins is the property the whole feature exists for: an analytics SDK the site declares does
|
|
35
|
+
* NOT start until the visitor grants ANALYTICS, starts when they do, and is STOPPED when they withdraw —
|
|
36
|
+
* while a provider resting on no consent (error monitoring) starts regardless and is never restarted.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const SKIP_KEY = WebsiteLabelKeySchema.parse('website.chrome.layout.skip-to-main-content');
|
|
40
|
+
// Every consent key resolves to itself, so buttons are found by the key they render.
|
|
41
|
+
const LABEL_PACK = Object.fromEntries([SKIP_KEY, ...WEBSITE_CONSENT_LABEL_KEYS].map((key) => [key as unknown as string, key as unknown as string]));
|
|
42
|
+
const label = (slot: WebsiteConsentLabelSlot): string => websiteConsentLabelKey(slot) as unknown as string;
|
|
43
|
+
|
|
44
|
+
function registryOf(modules: Record<string, unknown>[]): FrontendWebsiteProviderRegistry {
|
|
45
|
+
const map = new Map(modules.map((module) => [(module.metadata as { ref: string }).ref, module]));
|
|
46
|
+
return {
|
|
47
|
+
modules: map,
|
|
48
|
+
getProviderByRef: (ref: string) => map.get(ref),
|
|
49
|
+
getProvidersByProviderCapability: () => [],
|
|
50
|
+
findProviderByProviderCapability: () => undefined,
|
|
51
|
+
} as unknown as FrontendWebsiteProviderRegistry;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function provider(ref: string, capability: string, activate: ReturnType<typeof vi.fn>): Record<string, unknown> {
|
|
55
|
+
return {
|
|
56
|
+
metadata: defineProviderMetadata({ ref, packageName: '@test/providers', tier: ProviderTier.EXTENSION, origin: { kind: ProviderOriginKind.APPLICATION } }),
|
|
57
|
+
providerCapabilities: [capability],
|
|
58
|
+
protocols: ['FRONTEND_SDK'],
|
|
59
|
+
sdk: { descriptor: { packageName: `@vendor/${ref}` }, activate },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderSite(registry: FrontendWebsiteProviderRegistry | undefined, children: React.ReactNode = <p>page</p>, consentRecorder?: { requestAnonymous: (method: string, path: string, body: unknown) => Promise<unknown> }): void {
|
|
64
|
+
const runtime: WebsiteRuntimeContextValue = {
|
|
65
|
+
locale: AvailableLanguage.EN,
|
|
66
|
+
defaultLocale: AvailableLanguage.EN,
|
|
67
|
+
labelPack: LABEL_PACK,
|
|
68
|
+
designTokens: {
|
|
69
|
+
colors: {
|
|
70
|
+
background: '#fff', foreground: '#000', primary: '#000', primaryForeground: '#fff', accent: '#000', accentForeground: '#fff',
|
|
71
|
+
surface: '#fff', surfaceForeground: '#000', border: '#ccc', muted: '#eee', mutedForeground: '#666',
|
|
72
|
+
},
|
|
73
|
+
radii: { none: '0', sm: '2px', md: '4px', lg: '8px', xl: '12px', full: '9999px' },
|
|
74
|
+
},
|
|
75
|
+
navigation: { headerComponent: () => null, footerComponent: () => null },
|
|
76
|
+
skipToMainContentLabelKey: SKIP_KEY,
|
|
77
|
+
...(registry ? { frontendProviderRegistry: registry } : {}),
|
|
78
|
+
};
|
|
79
|
+
render(
|
|
80
|
+
<WebsiteRuntimeContextProvider value={runtime}>
|
|
81
|
+
<WebsitePageLayout pageRef="landing" {...(consentRecorder ? { consentRecorder } : {})}>{children}</WebsitePageLayout>
|
|
82
|
+
</WebsiteRuntimeContextProvider>,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beforeEach(() => window.localStorage.clear());
|
|
87
|
+
afterEach(cleanup);
|
|
88
|
+
|
|
89
|
+
describe('website consent storage', () => {
|
|
90
|
+
it('reads nothing when nothing, or nothing valid, is stored', () => {
|
|
91
|
+
expect(readWebsiteVisitorConsent()).toBeNull();
|
|
92
|
+
window.localStorage.setItem(WEBSITE_VISITOR_CONSENT_STORAGE_KEY, '{not json');
|
|
93
|
+
expect(readWebsiteVisitorConsent()).toBeNull();
|
|
94
|
+
window.localStorage.setItem(WEBSITE_VISITOR_CONSENT_STORAGE_KEY, JSON.stringify({ schemaVersion: 2, grantedPurposes: ['analytics'], decidedAt: new Date().toISOString() }));
|
|
95
|
+
expect(readWebsiteVisitorConsent()).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('round-trips a decision as a normalized SET, dropping purposes the vocabulary no longer has', () => {
|
|
99
|
+
const decidedAt = new Date('2026-09-13T08:00:00.000Z');
|
|
100
|
+
writeWebsiteVisitorConsent([ConsentPurpose.MARKETING, ConsentPurpose.ANALYTICS], decidedAt);
|
|
101
|
+
expect(readWebsiteVisitorConsent()).toEqual({ grantedPurposes: [ConsentPurpose.ANALYTICS, ConsentPurpose.MARKETING], decidedAt: decidedAt.toISOString() });
|
|
102
|
+
|
|
103
|
+
window.localStorage.setItem(WEBSITE_VISITOR_CONSENT_STORAGE_KEY, JSON.stringify({ schemaVersion: 1, grantedPurposes: ['analytics', 'retired'], decidedAt: decidedAt.toISOString() }));
|
|
104
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps an EMPTY decision distinct from no decision — declining is a recorded answer', () => {
|
|
108
|
+
writeWebsiteVisitorConsent([]);
|
|
109
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('still returns the decision when storage refuses the write', () => {
|
|
113
|
+
const refusing = { getItem: () => null, setItem: () => { throw new Error('quota'); } } as unknown as Storage;
|
|
114
|
+
expect(writeWebsiteVisitorConsent([ConsentPurpose.ANALYTICS], new Date(), refusing).grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('WebsitePageLayout enforces visitor consent on provider SDKs', () => {
|
|
119
|
+
it('starts error monitoring at once, withholds analytics until ACCEPT, and stops analytics on withdrawal', async () => {
|
|
120
|
+
const analyticsDeactivate = vi.fn();
|
|
121
|
+
const analyticsActivate = vi.fn().mockResolvedValue({ handle: 'posthog', deactivate: analyticsDeactivate });
|
|
122
|
+
const monitoringActivate = vi.fn().mockResolvedValue({ handle: 'sentry' });
|
|
123
|
+
renderSite(registryOf([
|
|
124
|
+
provider('posthog', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS, analyticsActivate),
|
|
125
|
+
provider('sentry', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING, monitoringActivate),
|
|
126
|
+
]));
|
|
127
|
+
|
|
128
|
+
await waitFor(() => expect(monitoringActivate).toHaveBeenCalledTimes(1));
|
|
129
|
+
// The panel asks, and nothing optional has started.
|
|
130
|
+
expect(await screen.findByRole('region', { name: label(WebsiteConsentLabelSlot.TITLE) })).toBeInTheDocument();
|
|
131
|
+
// The OPTIONAL box, reached through the row's own `data-website-consent-purpose` hook — the
|
|
132
|
+
// always-active row carries a checkbox too (checked, disabled), so an unqualified role query
|
|
133
|
+
// would now match two, and the accessible name is the row's title AND its explanation.
|
|
134
|
+
expect(document.querySelector<HTMLInputElement>('[data-website-consent-purpose="analytics"] input')).not.toBeChecked();
|
|
135
|
+
expect(analyticsActivate).not.toHaveBeenCalled();
|
|
136
|
+
|
|
137
|
+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })); });
|
|
138
|
+
await waitFor(() => expect(analyticsActivate).toHaveBeenCalledTimes(1));
|
|
139
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
140
|
+
expect(screen.queryByRole('region', { name: label(WebsiteConsentLabelSlot.TITLE) })).toBeNull();
|
|
141
|
+
|
|
142
|
+
// Withdraw through the always-reachable reopen button.
|
|
143
|
+
fireEvent.click(screen.getByRole('button', { name: label(WebsiteConsentLabelSlot.MANAGE) }));
|
|
144
|
+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: label(WebsiteConsentLabelSlot.DECLINE_ALL) })); });
|
|
145
|
+
await waitFor(() => expect(analyticsDeactivate).toHaveBeenCalledTimes(1));
|
|
146
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([]);
|
|
147
|
+
// Error monitoring was never restarted by any of it.
|
|
148
|
+
expect(monitoringActivate).toHaveBeenCalledTimes(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('honours a stored refusal: no panel, no analytics, and a way to change it', async () => {
|
|
152
|
+
writeWebsiteVisitorConsent([]);
|
|
153
|
+
const analyticsActivate = vi.fn().mockResolvedValue({ handle: 'posthog' });
|
|
154
|
+
renderSite(registryOf([provider('posthog', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS, analyticsActivate)]));
|
|
155
|
+
|
|
156
|
+
expect(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.MANAGE) })).toBeInTheDocument();
|
|
157
|
+
expect(screen.queryByRole('region', { name: label(WebsiteConsentLabelSlot.TITLE) })).toBeNull();
|
|
158
|
+
expect(analyticsActivate).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('shows no consent surface at all on a site whose providers rest on no consent', async () => {
|
|
162
|
+
const monitoringActivate = vi.fn().mockResolvedValue({ handle: 'sentry' });
|
|
163
|
+
renderSite(registryOf([provider('sentry', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING, monitoringActivate)]));
|
|
164
|
+
await waitFor(() => expect(monitoringActivate).toHaveBeenCalledTimes(1));
|
|
165
|
+
expect(screen.queryByRole('region')).toBeNull();
|
|
166
|
+
expect(screen.queryByRole('button', { name: label(WebsiteConsentLabelSlot.MANAGE) })).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* #1258 — what a visitor can actually SEE in the panel.
|
|
172
|
+
*
|
|
173
|
+
* The banner showed a tick box labelled "Analytics" and nothing else. That is a category, not
|
|
174
|
+
* something a visitor can weigh, and consent is only valid if it is INFORMED — so the panel was
|
|
175
|
+
* asking for a decision it had not given anybody the means to make. A marketing-site consent panel
|
|
176
|
+
* is also the surface with the tightest constraints anywhere in the product: it must state what runs
|
|
177
|
+
* regardless, explain each choice, and never push its own refusal below the fold.
|
|
178
|
+
*/
|
|
179
|
+
describe('the visitor panel shows the WHOLE picture, not only the tick boxes', () => {
|
|
180
|
+
const analytics = () => provider('posthog', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS, vi.fn().mockResolvedValue({ handle: 'posthog' }));
|
|
181
|
+
|
|
182
|
+
it('explains each purpose, states what is always active, and keeps the actions out of the scroll box', async () => {
|
|
183
|
+
renderSite(registryOf([analytics()]), <p>page</p>);
|
|
184
|
+
|
|
185
|
+
// Each offered purpose carries its explanation, not only its name.
|
|
186
|
+
expect(await screen.findByText(websiteConsentPurposeLabelKey(ConsentPurpose.ANALYTICS) as unknown as string)).toBeInTheDocument();
|
|
187
|
+
expect(screen.getByText(websiteConsentPurposeDescriptionLabelKey(ConsentPurpose.ANALYTICS) as unknown as string)).toBeInTheDocument();
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* The always-active row is STATED, and carries a real control — checked and disabled. A row with
|
|
191
|
+
* no control does not read as "cannot be changed": a visitor scans DOWN the column of boxes, and
|
|
192
|
+
* a gap there reads as a row that was forgotten. The disabled assertion is also what holds the
|
|
193
|
+
* line that this is not a purpose — the moment the box is operable, the panel is offering a
|
|
194
|
+
* choice about processing that has none.
|
|
195
|
+
*/
|
|
196
|
+
expect(screen.getByText(label(WebsiteConsentLabelSlot.ESSENTIAL_TITLE))).toBeInTheDocument();
|
|
197
|
+
const boxes = screen.getAllByRole('checkbox');
|
|
198
|
+
// The COUNT first: a destructure of the first two cannot see a third row appearing, and the order
|
|
199
|
+
// it depends on would be unstated. One always-active row, one purpose in use.
|
|
200
|
+
expect(boxes).toHaveLength(2);
|
|
201
|
+
const [essentialBox, analyticsBox] = boxes;
|
|
202
|
+
expect(essentialBox).toBeChecked();
|
|
203
|
+
expect(essentialBox).toBeDisabled();
|
|
204
|
+
expect(analyticsBox).not.toBeChecked();
|
|
205
|
+
expect(analyticsBox).toBeEnabled();
|
|
206
|
+
|
|
207
|
+
/*
|
|
208
|
+
* And no SAVE: with one purpose in use, decline-all and accept-all already cover the whole space,
|
|
209
|
+
* so a third button could only repeat them — which is exactly the confusion it caused.
|
|
210
|
+
*/
|
|
211
|
+
expect(screen.queryByRole('button', { name: label(WebsiteConsentLabelSlot.SAVE) })).toBeNull();
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
* The rows scroll; the panel does not grow. A panel that grows with its content pushes accept and
|
|
215
|
+
* reject off the bottom of a short screen, and a refusal that cannot be reached is a refusal that
|
|
216
|
+
* was not offered. Asserting the ACCEPT button is outside the scroll box is what catches a
|
|
217
|
+
* "simplification" that puts the whole panel in one scroller.
|
|
218
|
+
*/
|
|
219
|
+
const panel = screen.getByRole('region');
|
|
220
|
+
expect(panel.style.maxHeight).toBe('85dvh');
|
|
221
|
+
/*
|
|
222
|
+
* Anchored on the scroll box's OWN hook. `closest('div[style*="auto"]')` matched the essential
|
|
223
|
+
* ROW — its grid template is `auto 1fr` — so it resolved to a box that is not the scroller and
|
|
224
|
+
* both following assertions passed for free, including with the scroller deleted.
|
|
225
|
+
*/
|
|
226
|
+
const scrollBox = document.querySelector('[data-website-consent-rows]');
|
|
227
|
+
expect(scrollBox, 'the purpose rows must sit in their own scroll box').not.toBeNull();
|
|
228
|
+
// …and it must actually SCROLL. Containment alone holds for a plain div, so deleting the
|
|
229
|
+
// overflow — the change that puts the rows' height back onto the panel — would pass without this.
|
|
230
|
+
expect((scrollBox as HTMLElement).style.overflowY).toBe('auto');
|
|
231
|
+
expect(scrollBox!.contains(screen.getByText(label(WebsiteConsentLabelSlot.ESSENTIAL_TITLE))), 'the rows must be INSIDE it').toBe(true);
|
|
232
|
+
expect(scrollBox!.contains(screen.getByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })), 'the actions must not').toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('WebsiteConsentGate', () => {
|
|
237
|
+
it('always renders essential content, and consent-based content only once its purpose is granted', async () => {
|
|
238
|
+
renderSite(undefined, (
|
|
239
|
+
<>
|
|
240
|
+
<WebsiteConsentGate><form aria-label="contact" /></WebsiteConsentGate>
|
|
241
|
+
<WebsiteConsentGate purpose={ConsentPurpose.MARKETING} fallback={<p>withheld</p>}><p>pixel</p></WebsiteConsentGate>
|
|
242
|
+
</>
|
|
243
|
+
));
|
|
244
|
+
expect(screen.getByRole('form', { name: 'contact' })).toBeInTheDocument();
|
|
245
|
+
expect(await screen.findByText('withheld')).toBeInTheDocument();
|
|
246
|
+
expect(screen.queryByText('pixel')).toBeNull();
|
|
247
|
+
|
|
248
|
+
await act(async () => { writeWebsiteVisitorConsent([ConsentPurpose.MARKETING]); });
|
|
249
|
+
expect(await screen.findByText('pixel')).toBeInTheDocument();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* #1258 Step 1c — the visitor's decision reaching the server, so it can follow them to an account.
|
|
255
|
+
*
|
|
256
|
+
* What these pin is the pair of properties the carry depends on: the decision is recorded with the
|
|
257
|
+
* set that was actually OFFERED (without which the application cannot tell a decline from a
|
|
258
|
+
* never-asked), and the local decision is never hostage to that request succeeding.
|
|
259
|
+
*/
|
|
260
|
+
describe('a visitor decision is recorded server-side when the site wires a recorder', () => {
|
|
261
|
+
const analytics = () => provider('posthog', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS, vi.fn().mockResolvedValue({ handle: 'posthog' }));
|
|
262
|
+
|
|
263
|
+
it('posts the granted set AND the offered set to the anonymous consent route', async () => {
|
|
264
|
+
const requestAnonymous = vi.fn().mockResolvedValue({});
|
|
265
|
+
renderSite(registryOf([analytics()]), <p>page</p>, { requestAnonymous });
|
|
266
|
+
|
|
267
|
+
await act(async () => { fireEvent.click(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })); });
|
|
268
|
+
|
|
269
|
+
await waitFor(() => expect(requestAnonymous).toHaveBeenCalledTimes(1));
|
|
270
|
+
const [method, path, body] = requestAnonymous.mock.calls[0] as [string, string, { grantedPurposes: string[]; offeredPurposes: string[] }];
|
|
271
|
+
// PUT, not POST. The door's operation borrows UPDATE_MANY; sending POST answered 404, and this
|
|
272
|
+
// provider's failure path is deliberately silent, so nothing surfaced it (#1258 Step 1d).
|
|
273
|
+
expect(method).toBe(ANONYMOUS_CONSENT_RECORD_METHOD);
|
|
274
|
+
expect(path).toBe(ANONYMOUS_CONSENT_RECORD_PATH);
|
|
275
|
+
expect(body.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
276
|
+
// The OFFERED set is what this panel actually showed — derived from the site's live providers,
|
|
277
|
+
// which is why it can differ from what the application would ask.
|
|
278
|
+
expect(body.offeredPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('records a DECLINE too — an empty grant is a decision and must carry as one', async () => {
|
|
282
|
+
const requestAnonymous = vi.fn().mockResolvedValue({});
|
|
283
|
+
renderSite(registryOf([analytics()]), <p>page</p>, { requestAnonymous });
|
|
284
|
+
|
|
285
|
+
await act(async () => { fireEvent.click(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.DECLINE_ALL) })); });
|
|
286
|
+
|
|
287
|
+
await waitFor(() => expect(requestAnonymous).toHaveBeenCalledTimes(1));
|
|
288
|
+
const [, , body] = requestAnonymous.mock.calls[0] as [string, string, { grantedPurposes: string[]; offeredPurposes: string[] }];
|
|
289
|
+
expect(body.grantedPurposes).toEqual([]);
|
|
290
|
+
expect(body.offeredPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('holds the decision locally even when the server write fails, and tells the visitor nothing', async () => {
|
|
294
|
+
const requestAnonymous = vi.fn().mockRejectedValue(new Error('offline'));
|
|
295
|
+
renderSite(registryOf([analytics()]), <p>page</p>, { requestAnonymous });
|
|
296
|
+
|
|
297
|
+
await act(async () => { fireEvent.click(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })); });
|
|
298
|
+
|
|
299
|
+
// The decision HELD: stored, panel closed. Losing the carry must never look like losing the choice.
|
|
300
|
+
await waitFor(() => expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]));
|
|
301
|
+
expect(screen.queryByRole('region', { name: label(WebsiteConsentLabelSlot.TITLE) })).toBeNull();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('posts nothing at all when the site wires no recorder — the pre-#1258 behaviour, unchanged', async () => {
|
|
305
|
+
renderSite(registryOf([analytics()]));
|
|
306
|
+
|
|
307
|
+
await act(async () => { fireEvent.click(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })); });
|
|
308
|
+
|
|
309
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
|
+
import { ConsentPurpose } from "@wildo-ai/saas-models/public-runtime";
|
|
4
|
+
|
|
3
5
|
import { InboundContactFormSchema, buildInboundContactCreateBody, type InboundContactFormValues } from "../inbound-contact-form.schema";
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -36,9 +38,14 @@ describe("buildInboundContactCreateBody — no synthetic legal receipt", () => {
|
|
|
36
38
|
expect(buildInboundContactCreateBody(values).email).toBe("lead@example.com");
|
|
37
39
|
});
|
|
38
40
|
|
|
39
|
-
it("maps the OPTIONAL marketing opt-in to the
|
|
40
|
-
const
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
41
|
+
it("maps the OPTIONAL marketing opt-in to the consented purpose SET — nothing granted when unticked", () => {
|
|
42
|
+
const purposes = (v: InboundContactFormValues): unknown => (buildInboundContactCreateBody(v).privacy as { consentedPurposes: unknown }).consentedPurposes;
|
|
43
|
+
expect(purposes({ ...values, marketingConsent: true })).toEqual([ConsentPurpose.MARKETING]);
|
|
44
|
+
expect(purposes({ ...values, marketingConsent: false })).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("records that the visitor decided, either way — the server re-stamps the instant", () => {
|
|
48
|
+
const privacy = buildInboundContactCreateBody(values).privacy as { consentDecidedAt?: string };
|
|
49
|
+
expect(typeof privacy.consentDecidedAt).toBe("string");
|
|
43
50
|
});
|
|
44
51
|
});
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Mirrors the non-excluded, non-backend-only CREATE inputs of `InboundContact`
|
|
13
13
|
* (`email` required; `firstName` / `lastName` / `phone` optional), plus ONE privacy
|
|
14
|
-
* signal: an OPTIONAL marketing opt-in (mapped to the `privacy.
|
|
15
|
-
*
|
|
14
|
+
* signal: an OPTIONAL marketing opt-in (mapped to the `privacy.consentedPurposes` set).
|
|
15
|
+
* There is no second, required agreement field: a governed notice, contract or
|
|
16
16
|
* consent interaction must use the separate exact-release receipt contract, because this
|
|
17
17
|
* DTO cannot manufacture legal evidence from a checkbox and a client timestamp.
|
|
18
18
|
* `relatedUserId` is intentionally absent — it is `.excludeFromCreate()` on the
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
import { z } from "zod";
|
|
23
23
|
|
|
24
|
+
import { ConsentPurpose } from "@wildo-ai/saas-models/public-runtime";
|
|
25
|
+
|
|
24
26
|
/**
|
|
25
27
|
* The wire payload POSTed to `/api/v1/inbound-contacts`. `privacy` matches the
|
|
26
28
|
* resource's `InboundContactPrivacySchema` shape (a nested consent object) so the
|
|
@@ -34,9 +36,10 @@ export const InboundContactFormSchema = z.object({
|
|
|
34
36
|
lastName: z.string().trim().max(200).optional(),
|
|
35
37
|
phone: z.string().trim().max(64).optional(),
|
|
36
38
|
/**
|
|
37
|
-
* OPTIONAL marketing opt-in — maps into the resource's `privacy.
|
|
38
|
-
*
|
|
39
|
-
* and
|
|
39
|
+
* OPTIONAL marketing opt-in — maps into the resource's `privacy.consentedPurposes` set.
|
|
40
|
+
* It is the only privacy field on this form, and it gates nothing: sending the form is the
|
|
41
|
+
* essential act and never depends on optional consent, so an unchecked box still submits,
|
|
42
|
+
* granting nothing.
|
|
40
43
|
*/
|
|
41
44
|
marketingConsent: z.boolean().default(false),
|
|
42
45
|
});
|
|
@@ -45,7 +48,7 @@ export type InboundContactFormValues = z.infer<typeof InboundContactFormSchema>;
|
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
50
|
* Build the exact JSON body the backend CREATE expects from validated form values.
|
|
48
|
-
* Keeps the `privacy` nesting + the consent
|
|
51
|
+
* Keeps the `privacy` nesting + the consent purpose mapping in ONE place so the form
|
|
49
52
|
* component stays declarative.
|
|
50
53
|
*/
|
|
51
54
|
export function buildInboundContactCreateBody(values: InboundContactFormValues): Record<string, unknown> {
|
|
@@ -55,9 +58,11 @@ export function buildInboundContactCreateBody(values: InboundContactFormValues):
|
|
|
55
58
|
...(values.lastName ? { lastName: values.lastName } : {}),
|
|
56
59
|
...(values.phone ? { phone: values.phone } : {}),
|
|
57
60
|
privacy: {
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
+
// The visitor SAW the opt-in and submitted, so this is a decision either way (#534): the set
|
|
62
|
+
// they granted — MARKETING or nothing — plus a CLAIM that they decided now. The backend's
|
|
63
|
+
// CREATE prefix replaces the instant with its own clock; a browser clock is never recorded.
|
|
64
|
+
consentedPurposes: values.marketingConsent ? [ConsentPurpose.MARKETING] : [],
|
|
65
|
+
consentDecidedAt: new Date().toISOString(),
|
|
61
66
|
},
|
|
62
67
|
};
|
|
63
68
|
}
|
|
@@ -109,10 +109,24 @@ export class WebsiteAnonymousSessionClient {
|
|
|
109
109
|
* JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.
|
|
110
110
|
*/
|
|
111
111
|
async postAnonymous<TResponse = unknown>(path: string, body: unknown): Promise<TResponse> {
|
|
112
|
+
return this.requestAnonymous<TResponse>('POST', path, body);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Send `body` to an anonymous endpoint with an explicit METHOD.
|
|
117
|
+
*
|
|
118
|
+
* `postAnonymous` above is the POST case and delegates here. This exists because not every anonymous
|
|
119
|
+
* door is a POST and the method is not guessable from the path: the visitor consent door declares
|
|
120
|
+
* `resourceOperationLike: UPDATE_MANY`, which the engine maps to PUT, so a POST to it answers 404 —
|
|
121
|
+
* and this client's one silent caller swallowed exactly that. A caller reads the door's method from
|
|
122
|
+
* the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside
|
|
123
|
+
* `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.
|
|
124
|
+
*/
|
|
125
|
+
async requestAnonymous<TResponse = unknown>(method: string, path: string, body: unknown): Promise<TResponse> {
|
|
112
126
|
this.ensureSessionStarted();
|
|
113
127
|
|
|
114
128
|
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
115
|
-
method
|
|
129
|
+
method,
|
|
116
130
|
headers: this.buildHeaders(),
|
|
117
131
|
body: JSON.stringify(body),
|
|
118
132
|
// Carry the parent-domain `__wildo_anon_session` cookie to the API so the
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import React, { useEffect, useId, useState, type ReactNode } from 'react';
|
|
2
|
+
import type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
|
|
3
|
+
|
|
4
|
+
import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';
|
|
5
|
+
import { WebsiteConsentStatus } from './WebsiteConsentContext';
|
|
6
|
+
import { useWebsiteConsent } from './useWebsiteConsent';
|
|
7
|
+
import {
|
|
8
|
+
WebsiteConsentLabelSlot,
|
|
9
|
+
websiteConsentLabelKey,
|
|
10
|
+
websiteConsentPurposeDescriptionLabelKey,
|
|
11
|
+
websiteConsentPurposeLabelKey,
|
|
12
|
+
} from '../../schemas/label-keys/website-consent-label-keys.schemas';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @wildo_source:part:start saas.website.consent.banner facet:layer:core facet:family:website
|
|
16
|
+
*
|
|
17
|
+
* `WebsiteConsentBanner` — the framework's default visitor consent panel (#534).
|
|
18
|
+
*
|
|
19
|
+
* Rendered by `WebsitePageLayout` when the site runs a consent-based provider and the visitor has not
|
|
20
|
+
* decided, or re-opened the panel. A site replaces it by passing `consentBannerComponent` to its bridge
|
|
21
|
+
* island; a replacement reads `useWebsiteConsent()` exactly as this one does and must keep its three
|
|
22
|
+
* properties:
|
|
23
|
+
*
|
|
24
|
+
* 1. **No box is pre-ticked.** A pre-ticked box is not a choice.
|
|
25
|
+
* 2. **Declining is as easy as accepting**, and is RECORDED — so the panel does not return on every page.
|
|
26
|
+
* 3. **Only purposes in use are offered** (`purposesInUse`), each named by its label.
|
|
27
|
+
*
|
|
28
|
+
* Styled only through the site's `--website-*` design tokens, like the skip link, so it needs no
|
|
29
|
+
* stylesheet and cannot be shadowed by a utility class.
|
|
30
|
+
*/
|
|
31
|
+
function useWebsiteConsentSlotLabel(slot: WebsiteConsentLabelSlot): string {
|
|
32
|
+
return useWebsiteLabelByKey(websiteConsentLabelKey(slot));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every row has the SAME shape: the control, then the name on its line, then the explanation beneath
|
|
37
|
+
* the NAME rather than beneath the control — a two-column grid, so the second line starts where the
|
|
38
|
+
* first line's words do and the row stays scannable.
|
|
39
|
+
*
|
|
40
|
+
* That includes the row that cannot be changed, which carries a real checkbox, CHECKED and DISABLED,
|
|
41
|
+
* rather than a text status. A person scanning the panel reads DOWN the column of controls, and a row
|
|
42
|
+
* whose control is missing does not read as "cannot be changed" — it reads as a row that was
|
|
43
|
+
* forgotten. A checked, disabled box says it in the vocabulary the rest of the list already uses, and
|
|
44
|
+
* a screen reader announces "checked, unavailable" without any extra words.
|
|
45
|
+
*/
|
|
46
|
+
const ROW_STYLE: React.CSSProperties = {
|
|
47
|
+
display: 'grid',
|
|
48
|
+
gridTemplateColumns: 'auto 1fr',
|
|
49
|
+
columnGap: '0.625rem',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
padding: '0.5rem 0.75rem',
|
|
52
|
+
borderBottom: '1px solid var(--website-colors-border, #ccc)',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const ROW_DESCRIPTION_STYLE: React.CSSProperties = { margin: 0, gridColumn: 2, fontSize: '0.75rem', opacity: 0.8 };
|
|
56
|
+
|
|
57
|
+
/** Slightly larger than a default box: this is the one control the whole panel exists to offer. */
|
|
58
|
+
const ROW_CONTROL_STYLE: React.CSSProperties = { width: '1.15rem', height: '1.15rem' };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One purpose: its NAME, what agreeing to it MEANS for this visitor, and the control.
|
|
62
|
+
*
|
|
63
|
+
* The explanation is the half that was missing. A tick box labelled "Analytics" is a category, not
|
|
64
|
+
* something a visitor can weigh, and consent is only valid if it is informed — which is why every
|
|
65
|
+
* specialized consent platform pairs the two and why the label key is derived from the vocabulary,
|
|
66
|
+
* so a purpose can never arrive with a name and no explanation.
|
|
67
|
+
*/
|
|
68
|
+
function PurposeChoice({ purpose, checked, onChange }: { purpose: ConsentPurpose; checked: boolean; onChange: (checked: boolean) => void }): ReactNode {
|
|
69
|
+
const label = useWebsiteLabelByKey(websiteConsentPurposeLabelKey(purpose));
|
|
70
|
+
const description = useWebsiteLabelByKey(websiteConsentPurposeDescriptionLabelKey(purpose));
|
|
71
|
+
return (
|
|
72
|
+
<label data-website-consent-purpose={purpose} style={{ ...ROW_STYLE, cursor: 'pointer' }}>
|
|
73
|
+
<input type="checkbox" checked={checked} style={ROW_CONTROL_STYLE} onChange={(event) => onChange(event.target.checked)} />
|
|
74
|
+
<span style={{ fontWeight: 600 }}>{label}</span>
|
|
75
|
+
<span style={ROW_DESCRIPTION_STYLE}>{description}</span>
|
|
76
|
+
</label>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const BUTTON_STYLE: React.CSSProperties = {
|
|
81
|
+
padding: '0.5rem 1rem',
|
|
82
|
+
borderRadius: 'var(--website-radii-md, 0.375rem)',
|
|
83
|
+
border: '1px solid var(--website-colors-border, #ccc)',
|
|
84
|
+
background: 'var(--website-colors-background, #fff)',
|
|
85
|
+
color: 'var(--website-colors-foreground, #000)',
|
|
86
|
+
cursor: 'pointer',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function WebsiteConsentBanner(): ReactNode {
|
|
90
|
+
const consent = useWebsiteConsent();
|
|
91
|
+
const headingId = useId();
|
|
92
|
+
const [selected, setSelected] = useState<readonly ConsentPurpose[]>(consent.grantedPurposes);
|
|
93
|
+
// Re-seed from the stored decision each time the panel is re-opened, so it shows what is in force.
|
|
94
|
+
useEffect(() => setSelected(consent.grantedPurposes), [consent.grantedPurposes, consent.isSettingsOpen]);
|
|
95
|
+
|
|
96
|
+
const title = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.TITLE);
|
|
97
|
+
const description = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.DESCRIPTION);
|
|
98
|
+
const acceptAll = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ACCEPT_ALL);
|
|
99
|
+
const declineAll = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.DECLINE_ALL);
|
|
100
|
+
const save = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.SAVE);
|
|
101
|
+
const essentialTitle = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ESSENTIAL_TITLE);
|
|
102
|
+
const essentialDescription = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ESSENTIAL_DESCRIPTION);
|
|
103
|
+
|
|
104
|
+
const offered = consent.purposesInUse;
|
|
105
|
+
// Purposes the panel does not offer keep their stored grant; offered ones are granted exactly when ticked.
|
|
106
|
+
const choose = (chosen: readonly ConsentPurpose[]): void =>
|
|
107
|
+
consent.decide([...consent.grantedPurposes.filter((purpose) => !offered.includes(purpose)), ...chosen]);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<section
|
|
111
|
+
role="region"
|
|
112
|
+
aria-labelledby={headingId}
|
|
113
|
+
data-website-consent-banner={consent.status === WebsiteConsentStatus.UNDECIDED ? 'undecided' : 'settings'}
|
|
114
|
+
style={{
|
|
115
|
+
/*
|
|
116
|
+
* BOTTOM-RIGHT CORNER, and the three properties together are what make that work on a phone
|
|
117
|
+
* too. `left` and `right` both bound the box to the viewport with a gutter; `maxWidth` caps it
|
|
118
|
+
* on a wide screen; `marginLeft: auto` pushes the remaining slack to the left so the box hugs
|
|
119
|
+
* the right edge. On a narrow screen the gutters bind before the cap does and it simply spans
|
|
120
|
+
* the width — a corner popup that overflowed a phone would be worse than a bar.
|
|
121
|
+
*
|
|
122
|
+
* Inline styles, not a media query: this ships into a static marketing page with no stylesheet
|
|
123
|
+
* of its own, which is the same constraint that keeps the rest of this file inline.
|
|
124
|
+
*/
|
|
125
|
+
position: 'fixed',
|
|
126
|
+
left: '1rem',
|
|
127
|
+
right: '1rem',
|
|
128
|
+
bottom: '1rem',
|
|
129
|
+
zIndex: 9000,
|
|
130
|
+
maxWidth: '30rem',
|
|
131
|
+
marginLeft: 'auto',
|
|
132
|
+
/*
|
|
133
|
+
* The panel never exceeds the viewport, and its ACTIONS are never what gets cut off: the rows
|
|
134
|
+
* scroll inside `maxHeight`, the buttons sit outside that box. A panel that grows with its
|
|
135
|
+
* content pushes accept and reject off the bottom of a short screen — and a refusal that
|
|
136
|
+
* cannot be reached is a refusal that was not offered, whatever the markup says. `dvh` rather
|
|
137
|
+
* than `vh` because a mobile browser's toolbars make `vh` taller than the visible page.
|
|
138
|
+
*/
|
|
139
|
+
maxHeight: '85dvh',
|
|
140
|
+
padding: '0.875rem 1rem',
|
|
141
|
+
display: 'flex',
|
|
142
|
+
flexDirection: 'column',
|
|
143
|
+
gap: '0.625rem',
|
|
144
|
+
background: 'var(--website-colors-surface, #fff)',
|
|
145
|
+
color: 'var(--website-colors-surface-foreground, #000)',
|
|
146
|
+
border: '1px solid var(--website-colors-border, #ccc)',
|
|
147
|
+
borderRadius: 'var(--website-radii-lg, 0.5rem)',
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<h2 id={headingId} style={{ margin: 0, fontSize: '1.125rem' }}>{title}</h2>
|
|
151
|
+
<p style={{ margin: 0 }}>{description}</p>
|
|
152
|
+
{/* `data-website-consent-rows` is the scroll box's own hook. A test anchoring on a style
|
|
153
|
+
substring instead matched the ROW (whose grid template also contains "auto") and asserted
|
|
154
|
+
nothing — the same vacuous pin the app tier already had to repair. */}
|
|
155
|
+
<div
|
|
156
|
+
data-website-consent-rows=""
|
|
157
|
+
style={{ minHeight: 0, flex: 1, overflowY: 'auto', border: '1px solid var(--website-colors-border, #ccc)', borderRadius: 'var(--website-radii-md, 0.375rem)' }}
|
|
158
|
+
>
|
|
159
|
+
{/*
|
|
160
|
+
* The always-active row, first, carrying a CHECKED and DISABLED box. It is not a purpose the
|
|
161
|
+
* visitor decides — which is exactly why it is stated: a panel listing only the optional
|
|
162
|
+
* purposes leaves them unable to see what is happening on the site at all, which is the
|
|
163
|
+
* question they opened it to answer.
|
|
164
|
+
*
|
|
165
|
+
* It carried a text status instead until 2026-09-20, on the argument that a disabled control
|
|
166
|
+
* invites a click that cannot work. The reversal: a visitor scans DOWN the column of boxes,
|
|
167
|
+
* and a gap there does not read as "cannot be changed", it reads as a row that was
|
|
168
|
+
* forgotten. A disabled box says it in the vocabulary the rest of the list already uses.
|
|
169
|
+
*/}
|
|
170
|
+
<div data-website-consent-essential="" style={ROW_STYLE}>
|
|
171
|
+
{/*
|
|
172
|
+
* `aria-label`, because this row is a `div` rather than a `<label>` — nothing else names
|
|
173
|
+
* the control, and a checkbox with no accessible name is announced as an unlabelled
|
|
174
|
+
* checkbox. Its own note claimed AT would announce "checked, unavailable"; it announced
|
|
175
|
+
* no words at all.
|
|
176
|
+
*
|
|
177
|
+
* `readOnly` is gone: it is inert on a checkbox per HTML, and leaving it invites a later
|
|
178
|
+
* reader to delete `disabled` believing the lock survives.
|
|
179
|
+
*/}
|
|
180
|
+
<input type="checkbox" checked disabled aria-label={essentialTitle} style={ROW_CONTROL_STYLE} />
|
|
181
|
+
<span style={{ fontWeight: 600 }}>{essentialTitle}</span>
|
|
182
|
+
<span style={ROW_DESCRIPTION_STYLE}>{essentialDescription}</span>
|
|
183
|
+
</div>
|
|
184
|
+
{offered.map((purpose) => (
|
|
185
|
+
<PurposeChoice
|
|
186
|
+
key={purpose}
|
|
187
|
+
purpose={purpose}
|
|
188
|
+
checked={selected.includes(purpose)}
|
|
189
|
+
onChange={(checked) => setSelected((current) => (checked ? [...current.filter((entry) => entry !== purpose), purpose] : current.filter((entry) => entry !== purpose)))}
|
|
190
|
+
/>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
|
194
|
+
<button type="button" style={BUTTON_STYLE} onClick={() => choose([])}>{declineAll}</button>
|
|
195
|
+
{/*
|
|
196
|
+
* SAVE appears only when a MIXED choice exists to save — two or more purposes in use.
|
|
197
|
+
*
|
|
198
|
+
* With one purpose the button is unanswerable: "decline all" and "accept all" already cover
|
|
199
|
+
* the whole space, so a third action beside them describes nothing a visitor can picture and
|
|
200
|
+
* reads as a question rather than an answer. Derived from the offered set, so a site that
|
|
201
|
+
* adds a second purpose gets the control with no edit here.
|
|
202
|
+
*/}
|
|
203
|
+
{offered.length > 1 && (
|
|
204
|
+
<button type="button" style={BUTTON_STYLE} onClick={() => choose(selected.filter((purpose) => offered.includes(purpose)))}>{save}</button>
|
|
205
|
+
)}
|
|
206
|
+
<button type="button" style={BUTTON_STYLE} onClick={() => choose(offered)}>{acceptAll}</button>
|
|
207
|
+
</div>
|
|
208
|
+
</section>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The reopen affordance: always reachable once a visitor decided, so every choice can be changed. */
|
|
213
|
+
export function WebsiteConsentSettingsButton(): ReactNode {
|
|
214
|
+
const consent = useWebsiteConsent();
|
|
215
|
+
const manage = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.MANAGE);
|
|
216
|
+
return (
|
|
217
|
+
<button type="button" data-website-consent-manage="" style={BUTTON_STYLE} onClick={consent.openSettings}>
|
|
218
|
+
{manage}
|
|
219
|
+
</button>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
/** @wildo_source:part:end saas.website.consent.banner */
|