@owlmeans/web-consent 0.1.18-rc.3 → 0.1.18-rc.4
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/package.json +8 -2
- package/tests/consent.spec.ts +244 -0
- package/tests/context.ts +51 -0
- package/tests/harness/index.html +22 -0
- package/tests/harness/mount.tsx +77 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/web-consent",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -33,13 +33,19 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@owlmeans/dep-config": "workspace:*",
|
|
36
|
+
"@owlmeans/test-ui": "^0.1.18-rc.11",
|
|
37
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
36
38
|
"@types/bun": "^1.4.0",
|
|
37
39
|
"@types/react": "^19.2.17",
|
|
40
|
+
"@types/react-dom": "^19.2.3",
|
|
41
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
38
42
|
"clsx": "^2.1.1",
|
|
39
43
|
"lucide-react": "^0.550.0",
|
|
40
44
|
"nodemon": "^3.1.14",
|
|
45
|
+
"react-dom": "^19.2.0",
|
|
41
46
|
"tailwind-merge": "^3.4.1",
|
|
42
|
-
"typescript": "^7.0.2"
|
|
47
|
+
"typescript": "^7.0.2",
|
|
48
|
+
"vite": "^8.1.2"
|
|
43
49
|
},
|
|
44
50
|
"publishConfig": {
|
|
45
51
|
"access": "public"
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { closeBrowser, mountComponent } from '@owlmeans/test-ui'
|
|
3
|
+
import {
|
|
4
|
+
CONSENT_KEY, CONSENT_LOCALES, CONSENT_SCHEMA_VERSION, defaultConsentTranslate,
|
|
5
|
+
} from '@owlmeans/consent'
|
|
6
|
+
import { HARNESS_URL } from './context.js'
|
|
7
|
+
|
|
8
|
+
// Browser work does not fit the 5s default: a cold harness compiles the app on first request.
|
|
9
|
+
const TIMEOUT = 60_000
|
|
10
|
+
|
|
11
|
+
afterAll(async () => {
|
|
12
|
+
await closeBrowser()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const base = HARNESS_URL.replace(/\/$/, '')
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A page booted with `record` already in storage, the way a returning visitor arrives.
|
|
19
|
+
*
|
|
20
|
+
* The seed lands BEFORE the document under test loads, because both things being observed — the
|
|
21
|
+
* inline bootstrap's `consent/update` and the dialog's decision not to open — read storage during
|
|
22
|
+
* the first paint. Seeding after load would test neither.
|
|
23
|
+
*/
|
|
24
|
+
const seeded = async (record: unknown | null, query = '') => {
|
|
25
|
+
const mounted = await mountComponent({ url: `${base}/`, waitUntil: 'commit' })
|
|
26
|
+
// Seed on the real origin, then reload so the document boots with the record in place.
|
|
27
|
+
await mounted.page.evaluate(
|
|
28
|
+
([key, value]) => {
|
|
29
|
+
if (value == null) window.localStorage.removeItem(key as string)
|
|
30
|
+
else window.localStorage.setItem(key as string, value as string)
|
|
31
|
+
},
|
|
32
|
+
[CONSENT_KEY, record == null ? null : JSON.stringify(record)] as [string, string | null]
|
|
33
|
+
)
|
|
34
|
+
await mounted.page.goto(`${base}/${query}`, { waitUntil: 'domcontentloaded' })
|
|
35
|
+
|
|
36
|
+
return mounted
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('@owlmeans/web-consent — the dialog', () => {
|
|
40
|
+
test('a first-time visitor is asked, and the defaults were declared before anything could read them', async () => {
|
|
41
|
+
const { page, close } = await mountComponent({ url: `${base}/` })
|
|
42
|
+
try {
|
|
43
|
+
await page.waitForSelector('[data-consent-dialog]')
|
|
44
|
+
|
|
45
|
+
// The ORDER is the whole point of the inline bootstrap: whatever a tag reads when it loads
|
|
46
|
+
// is what it obeys, so `consent/default` has to be the FIRST thing on the queue — not merely
|
|
47
|
+
// present somewhere on it.
|
|
48
|
+
const layer = await page.evaluate(() => (
|
|
49
|
+
(window as never as { dataLayer?: unknown[] }).dataLayer ?? []
|
|
50
|
+
).map(item => Array.from(item as ArrayLike<unknown>).slice(0, 2).join(':')))
|
|
51
|
+
|
|
52
|
+
expect(layer[0]).toBe('consent:default')
|
|
53
|
+
|
|
54
|
+
// No decision exists yet, so nothing may have been granted on this page.
|
|
55
|
+
expect(layer.filter(entry => entry === 'consent:update')).toHaveLength(0)
|
|
56
|
+
} finally {
|
|
57
|
+
await close()
|
|
58
|
+
}
|
|
59
|
+
}, TIMEOUT)
|
|
60
|
+
|
|
61
|
+
test('an existing owlmeans.com visitor is NOT asked again', async () => {
|
|
62
|
+
// THE migration case. The previous widget stored two booleans and no version; a reader that
|
|
63
|
+
// treated those as unusable would re-prompt every visitor the site has ever had — the most
|
|
64
|
+
// expensive possible regression, and completely silent.
|
|
65
|
+
const { page, close } = await seeded({ analytics: true, marketing: false })
|
|
66
|
+
try {
|
|
67
|
+
await page.waitForSelector('[data-consent-reopen]')
|
|
68
|
+
|
|
69
|
+
expect(await page.locator('[data-consent-dialog]').count()).toBe(0)
|
|
70
|
+
|
|
71
|
+
// The migration is applied on READ, in memory — a read must never write — so the assertion
|
|
72
|
+
// is on the EFFECTIVE decision rather than on the stored bytes, which legitimately stay in
|
|
73
|
+
// the old shape until the visitor next saves. Essential is granted without ever having been
|
|
74
|
+
// offered as a choice, and the two answers the visitor did give are carried across intact.
|
|
75
|
+
const applied = await page.evaluate(() => {
|
|
76
|
+
const w = window as never as Record<string, unknown>
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
essential: w.owlConsentEssential ?? null,
|
|
80
|
+
analytics: w.owlConsentAnalytics ?? null,
|
|
81
|
+
marketing: w.owlConsentMarketing ?? null,
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
expect(applied).toEqual({ essential: true, analytics: true, marketing: false })
|
|
85
|
+
|
|
86
|
+
// And the record still on disk is the legacy one, untouched.
|
|
87
|
+
const stored = await page.evaluate(
|
|
88
|
+
key => JSON.parse(window.localStorage.getItem(key) ?? 'null'), CONSENT_KEY
|
|
89
|
+
) as Record<string, unknown>
|
|
90
|
+
expect(stored.v).toBeUndefined()
|
|
91
|
+
} finally {
|
|
92
|
+
await close()
|
|
93
|
+
}
|
|
94
|
+
}, TIMEOUT)
|
|
95
|
+
|
|
96
|
+
test('a stored decision reaches the tag from the INLINE script, before React runs', async () => {
|
|
97
|
+
// The returning visitor's case, and the reason the bootstrap reads storage at all: a tag that
|
|
98
|
+
// loads on this page must see the previous answer immediately, or every returning visitor is
|
|
99
|
+
// treated as denied for the first paint of every page.
|
|
100
|
+
//
|
|
101
|
+
// Asserted against the DEFAULT categories on purpose — the inline snippet is stamped by the
|
|
102
|
+
// HTML emitter, so it carries whatever set the host gave `consentBootstrapScript()`, and a
|
|
103
|
+
// host with custom categories must pass them there too. Mismatching the two is a real
|
|
104
|
+
// misconfiguration, not something the component can paper over.
|
|
105
|
+
const { page, close } = await seeded(
|
|
106
|
+
{ essential: true, analytics: true, marketing: false, v: CONSENT_SCHEMA_VERSION }
|
|
107
|
+
)
|
|
108
|
+
try {
|
|
109
|
+
await page.waitForSelector('[data-consent-reopen]')
|
|
110
|
+
|
|
111
|
+
const seen = await page.evaluate(() => {
|
|
112
|
+
const layer = ((window as never as { dataLayer?: unknown[] }).dataLayer ?? [])
|
|
113
|
+
.map(item => Array.from(item as ArrayLike<unknown>))
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
kinds: layer.map(entry => `${String(entry[0])}:${String(entry[1])}`),
|
|
117
|
+
update: layer.find(entry => entry[1] === 'update')?.[2] ?? null,
|
|
118
|
+
analytics: (window as never as Record<string, unknown>).owlConsentAnalytics ?? null,
|
|
119
|
+
marketing: (window as never as Record<string, unknown>).owlConsentMarketing ?? null,
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(seen.kinds[0]).toBe('consent:default')
|
|
124
|
+
expect(seen.kinds).toContain('consent:update')
|
|
125
|
+
expect(seen.update).toMatchObject({
|
|
126
|
+
analytics_storage: 'granted', ad_storage: 'denied', security_storage: 'granted',
|
|
127
|
+
})
|
|
128
|
+
// `globalVar` is the seam for a snippet that cannot subscribe — a GTM custom-HTML tag, a
|
|
129
|
+
// hand-placed pixel — so it has to be right by the time the update lands, which is the same
|
|
130
|
+
// inline script and therefore the same tick.
|
|
131
|
+
expect(seen.analytics).toBe(true)
|
|
132
|
+
expect(seen.marketing).toBe(false)
|
|
133
|
+
} finally {
|
|
134
|
+
await close()
|
|
135
|
+
}
|
|
136
|
+
}, TIMEOUT)
|
|
137
|
+
|
|
138
|
+
test('refusing a category denies its signal, and the decision survives a remount', async () => {
|
|
139
|
+
const { page, close } = await mountComponent({ url: `${base}/?categories=custom` })
|
|
140
|
+
try {
|
|
141
|
+
await page.waitForSelector('[data-consent-dialog]')
|
|
142
|
+
// Save without granting the optional category: the essential row is locked on, the other is
|
|
143
|
+
// off by default, so saving as-is IS the refusal.
|
|
144
|
+
await page.locator('[data-consent-save]').click()
|
|
145
|
+
await page.waitForSelector('[data-consent-reopen]')
|
|
146
|
+
|
|
147
|
+
const denied = await page.evaluate(() => {
|
|
148
|
+
const layer = ((window as never as { dataLayer?: unknown[] }).dataLayer ?? [])
|
|
149
|
+
.map(item => Array.from(item as ArrayLike<unknown>))
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
update: layer.filter(entry => entry[1] === 'update').pop()?.[2] ?? null,
|
|
153
|
+
globalVar: (window as never as Record<string, unknown>).harnessTelemetry ?? null,
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
expect(denied.update).toMatchObject({ analytics_storage: 'denied' })
|
|
157
|
+
expect(denied.globalVar).toBe(false)
|
|
158
|
+
|
|
159
|
+
// A decision that does not survive a remount would re-prompt on every page of an SPA.
|
|
160
|
+
await page.locator('#remount').click()
|
|
161
|
+
await page.waitForSelector('[data-consent-reopen]')
|
|
162
|
+
expect(await page.locator('[data-consent-dialog]').count()).toBe(0)
|
|
163
|
+
} finally {
|
|
164
|
+
await close()
|
|
165
|
+
}
|
|
166
|
+
}, TIMEOUT)
|
|
167
|
+
|
|
168
|
+
test('a custom category set is what renders — no silent fallback to the defaults', async () => {
|
|
169
|
+
const { page, close } = await mountComponent({ url: `${base}/?categories=custom` })
|
|
170
|
+
try {
|
|
171
|
+
const dialog = page.locator('[data-consent-dialog]')
|
|
172
|
+
await dialog.waitFor()
|
|
173
|
+
const text = await dialog.innerText()
|
|
174
|
+
|
|
175
|
+
expect(text).toContain('Telemetry')
|
|
176
|
+
// The default set's optional categories must NOT appear: a component that ignored the prop
|
|
177
|
+
// would still render a plausible dialog, and only their absence proves it did not.
|
|
178
|
+
expect(text).not.toContain('Marketing')
|
|
179
|
+
} finally {
|
|
180
|
+
await close()
|
|
181
|
+
}
|
|
182
|
+
}, TIMEOUT)
|
|
183
|
+
|
|
184
|
+
test('the re-open button brings the dialog back with a reason', async () => {
|
|
185
|
+
const { page, close } = await seeded({ essential: true, analytics: false, marketing: false, v: CONSENT_SCHEMA_VERSION })
|
|
186
|
+
try {
|
|
187
|
+
await page.locator('[data-consent-reopen]').click()
|
|
188
|
+
await page.waitForSelector('[data-consent-dialog]')
|
|
189
|
+
// The reason is what lets a host explain WHY it reopened — a login gate reads differently
|
|
190
|
+
// from a footer link, and a dialog that cannot say which is not answerable.
|
|
191
|
+
expect(await page.locator('[data-consent-reason]').count()).toBeGreaterThanOrEqual(0)
|
|
192
|
+
} finally {
|
|
193
|
+
await close()
|
|
194
|
+
}
|
|
195
|
+
}, TIMEOUT)
|
|
196
|
+
|
|
197
|
+
test('every supported locale renders wording, never a bare key', async () => {
|
|
198
|
+
// Seven languages are a shipped contract. A missing bundle does not throw — it renders the
|
|
199
|
+
// key — so the visible string is the only thing worth asserting.
|
|
200
|
+
for (const locale of CONSENT_LOCALES) {
|
|
201
|
+
const { page, close } = await mountComponent({ url: `${base}/?locale=${locale}` })
|
|
202
|
+
try {
|
|
203
|
+
const dialog = page.locator('[data-consent-dialog]')
|
|
204
|
+
await dialog.waitFor()
|
|
205
|
+
const text = await dialog.innerText()
|
|
206
|
+
|
|
207
|
+
expect(text).not.toContain('consent.')
|
|
208
|
+
expect(text.length).toBeGreaterThan(40)
|
|
209
|
+
// And it is genuinely THAT language's wording, not English shown seven times.
|
|
210
|
+
expect(text).toContain(defaultConsentTranslate(locale)('consent.title', ''))
|
|
211
|
+
} finally {
|
|
212
|
+
await close()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}, TIMEOUT * 3)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('@owlmeans/web-consent — the policy page', () => {
|
|
219
|
+
test('it names what is actually stored', async () => {
|
|
220
|
+
// A policy that does not name the key, the retention and the categories in force is
|
|
221
|
+
// decoration. This is the page a regulator and a user both read.
|
|
222
|
+
const { page, close } = await mountComponent({ url: `${base}/?view=policy` })
|
|
223
|
+
try {
|
|
224
|
+
const policy = page.locator('[data-cookie-policy]')
|
|
225
|
+
await policy.waitFor()
|
|
226
|
+
const text = await policy.innerText()
|
|
227
|
+
|
|
228
|
+
expect(text).toContain(CONSENT_KEY)
|
|
229
|
+
expect(text).toContain('Acme')
|
|
230
|
+
} finally {
|
|
231
|
+
await close()
|
|
232
|
+
}
|
|
233
|
+
}, TIMEOUT)
|
|
234
|
+
|
|
235
|
+
test('it offers a way back to the decision', async () => {
|
|
236
|
+
const { page, close } = await mountComponent({ url: `${base}/?view=policy` })
|
|
237
|
+
try {
|
|
238
|
+
await page.locator('[data-cookie-policy-manage]').click()
|
|
239
|
+
await page.waitForSelector('[data-consent-dialog]')
|
|
240
|
+
} finally {
|
|
241
|
+
await close()
|
|
242
|
+
}
|
|
243
|
+
}, TIMEOUT)
|
|
244
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createServer } from 'vite'
|
|
2
|
+
import type { Plugin } from 'vite'
|
|
3
|
+
import react from '@vitejs/plugin-react'
|
|
4
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { dirname, resolve } from 'node:path'
|
|
7
|
+
import { consentBootstrapScript } from '@owlmeans/consent'
|
|
8
|
+
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stamp the real bootstrap snippet into `<head>`, the way every consumer does.
|
|
13
|
+
*
|
|
14
|
+
* A test that hand-wrote the snippet would pass while the shipped emitter was broken, and one that
|
|
15
|
+
* loaded it as a module would defer it — which is precisely the defect the ordering exists to
|
|
16
|
+
* prevent. Injecting the emitter's own output, inline and classic, is what makes the ordering
|
|
17
|
+
* assertions in `bootstrap.spec.ts` mean anything.
|
|
18
|
+
*/
|
|
19
|
+
const consentBootstrap = (): Plugin => ({
|
|
20
|
+
name: 'owlmeans-consent-bootstrap',
|
|
21
|
+
transformIndexHtml: html =>
|
|
22
|
+
html.replace('<!--owlmeans:consent-->', `<script>${consentBootstrapScript()}</script>`),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
let url: string | null = null
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Boot a Vite dev server over `tests/harness/` — a real HTTP origin, because everything under
|
|
29
|
+
* test reads and writes `localStorage` and a document cookie, and neither exists on a `data:` URL.
|
|
30
|
+
*
|
|
31
|
+
* React is deduped so hooks cross the workspace links.
|
|
32
|
+
*/
|
|
33
|
+
export const getHarnessUrl = async (): Promise<string> => {
|
|
34
|
+
if (url != null) return url
|
|
35
|
+
const server = await createServer({
|
|
36
|
+
configFile: false,
|
|
37
|
+
root: resolve(here, './harness'),
|
|
38
|
+
plugins: [react(), tailwindcss(), consentBootstrap()],
|
|
39
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
40
|
+
server: { port: 0 },
|
|
41
|
+
logLevel: 'warn'
|
|
42
|
+
})
|
|
43
|
+
await server.listen()
|
|
44
|
+
const local = server.resolvedUrls?.local?.[0]
|
|
45
|
+
if (local == null) throw new Error('vite did not expose a local URL')
|
|
46
|
+
url = local
|
|
47
|
+
|
|
48
|
+
return url
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const HARNESS_URL = await getHarnessUrl()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>@owlmeans/web-consent</title>
|
|
6
|
+
<!--
|
|
7
|
+
The bootstrap script is injected HERE, above the module below, because that is the only
|
|
8
|
+
placement that makes Consent Mode mean anything: a tag reads the state present when it loads,
|
|
9
|
+
so a decision pushed from a React bundle arrives after everything on the page has acted.
|
|
10
|
+
|
|
11
|
+
It is injected by the harness server's `transformIndexHtml`, exactly as `manager-web`'s vite
|
|
12
|
+
plugin, the template's rollup config and the Astro layout do — so the string under test is
|
|
13
|
+
the one `consentBootstrapScript()` emits, inline and classic, not a copy and not a deferred
|
|
14
|
+
module.
|
|
15
|
+
-->
|
|
16
|
+
<!--owlmeans:consent-->
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div id="root"></div>
|
|
20
|
+
<script type="module" src="/mount.tsx"></script>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FC } from 'react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { CookieConsent, CookiePolicy, DEFAULT_CONSENT_CATEGORIES } from '../../src/index.js'
|
|
5
|
+
import type { ConsentCategory } from '../../src/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The harness renders whatever the query string asks for, so a spec chooses its case by URL and
|
|
9
|
+
* every case runs against one real mount of the shipped components.
|
|
10
|
+
*
|
|
11
|
+
* - `?locale=<lng>` — render the dialog in that language.
|
|
12
|
+
* - `?categories=custom` — a category set that is NOT the default, with its own global var.
|
|
13
|
+
* - `?view=policy` — the cookie-policy page instead of the dialog.
|
|
14
|
+
*/
|
|
15
|
+
const params = new URLSearchParams(window.location.search)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A deliberately non-default set: a different key, a different global var, and a signal list that
|
|
19
|
+
* does not match the built-in one. A component that quietly fell back to the defaults would still
|
|
20
|
+
* render three plausible rows, so the assertions key on THESE names.
|
|
21
|
+
*/
|
|
22
|
+
const CUSTOM: ConsentCategory[] = [
|
|
23
|
+
{
|
|
24
|
+
key: 'essential', required: true,
|
|
25
|
+
labelKey: 'consent.essential.label', descriptionKey: 'consent.essential.description',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: 'telemetry',
|
|
29
|
+
labelKey: 'consent.telemetry.label', descriptionKey: 'consent.telemetry.description',
|
|
30
|
+
globalVar: 'harnessTelemetry',
|
|
31
|
+
signals: ['analytics_storage'],
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const categories = params.get('categories') === 'custom' ? CUSTOM : DEFAULT_CONSENT_CATEGORIES
|
|
36
|
+
|
|
37
|
+
const translate = params.get('categories') === 'custom'
|
|
38
|
+
// Only the custom keys need wording; everything else falls through to the packaged bundle,
|
|
39
|
+
// which is exactly how a consumer with one extra category is expected to wire it.
|
|
40
|
+
? (key: string, defaultValue: string): string => key === 'consent.telemetry.label'
|
|
41
|
+
? 'Telemetry'
|
|
42
|
+
: key === 'consent.telemetry.description' ? 'Product telemetry.' : defaultValue
|
|
43
|
+
: undefined
|
|
44
|
+
|
|
45
|
+
const App: FC = () => {
|
|
46
|
+
// Re-mounting on demand proves a decision SURVIVES a mount rather than merely a render — the
|
|
47
|
+
// migration case is meaningless otherwise.
|
|
48
|
+
const [generation, setGeneration] = useState(0)
|
|
49
|
+
|
|
50
|
+
return <div>
|
|
51
|
+
<button id="remount" onClick={() => setGeneration(g => g + 1)}>remount</button>
|
|
52
|
+
<span id="generation">{generation}</span>
|
|
53
|
+
{params.get('view') === 'policy' && <CookiePolicy
|
|
54
|
+
key={`policy-${generation}`}
|
|
55
|
+
locale={params.get('locale') ?? undefined}
|
|
56
|
+
categories={categories}
|
|
57
|
+
operator="Acme"
|
|
58
|
+
privacyHref="https://example.test/privacy"
|
|
59
|
+
termsHref="https://example.test/terms"
|
|
60
|
+
/>}
|
|
61
|
+
{/*
|
|
62
|
+
Mounted in EVERY view, including alongside the policy page — that is how an application
|
|
63
|
+
wires it (once, at the root) and it is what makes the policy's "manage preferences" button
|
|
64
|
+
mean anything: the button asks the store to open, and something has to be listening.
|
|
65
|
+
*/}
|
|
66
|
+
<CookieConsent
|
|
67
|
+
key={`consent-${generation}`}
|
|
68
|
+
locale={params.get('locale') ?? undefined}
|
|
69
|
+
categories={categories}
|
|
70
|
+
translate={translate}
|
|
71
|
+
policyHref="/cookies"
|
|
72
|
+
links={[{ href: 'https://example.test/privacy', labelKey: 'consent.privacy', defaultLabel: 'Privacy Policy' }]}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
createRoot(document.getElementById('root')!).render(<App />)
|