@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,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 dataProcessingConsent tier", () => {
40
- const tier = (v: InboundContactFormValues): string => (buildInboundContactCreateBody(v).privacy as { dataProcessingConsent: string }).dataProcessingConsent;
41
- expect(tier({ ...values, marketingConsent: true })).toBe("marketing");
42
- expect(tier({ ...values, marketingConsent: false })).toBe("basic");
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.dataProcessingConsent`
15
- * tier). There is no second, required agreement field: a governed notice, contract or
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.dataProcessingConsent`
38
- * tier ('marketing' vs the BASIC default). It is the only privacy field on this form,
39
- * and it gates nothing: an unchecked box still submits, at the BASIC tier.
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 enum mapping in ONE place so the form
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
- // OPTIONAL marketing tier: 'marketing' when the visitor opted in, else the
59
- // resource's BASIC default.
60
- dataProcessingConsent: values.marketingConsent ? "marketing" : "basic",
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: 'POST',
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 */