apps/web/src/components/CookieConsent/CookieConsent.tsx
1import {
2 DEFAULT_COOKIE_CONSENT_REGION,
3 normalizeCookieConsentRegion,
4} from '@local/config'
5import { fireGtmEvent } from '@local/gtm/src'
6import { resolveCookieConsentCopy } from '@local/sanity'
7import type {
8 CookieConsentRegionCopy,
9 CookieConsentSettings,
10} from '@local/sanity/types/cookie-consent'
11import { createAsync } from '@solidjs/router'
12import {
13 createEffect,
14 createMemo,
15 createSignal,
16 createUniqueId,
17 onCleanup,
18 onMount,
19 Show,
20} from 'solid-js'
21import { CookieToggle } from '~/components/CookieConsent/CookieToggle'
22import { applyConsentToScripts } from '~/lib/cookie-consent/apply-consent'
23import {
24 defaultConsent,
25 readStoredConsent,
26 type StoredConsent,
27 writeStoredConsent,
28} from '~/lib/cookie-consent/consent-state'
29import {
30 type CookieStrategy,
31 defaultNonEssentialToggles,
32 getEffectiveConsentForScripts,
33 parseCookieStrategy,
34} from '~/lib/cookie-consent/cookie-strategy'
35import {
36 getCookieConsentRegionFromRequest,
37 readCookieConsentRegionFromDocument,
38} from '~/lib/cookie-consent/get-cookie-consent-data'
39import { openCookiePreferences } from '~/lib/cookie-consent/open-preferences'
40
41const D = {
42 acceptAll: 'Accept all',
43 reject: 'Reject non-essential',
44 manage: 'Cookie settings',
45 privacy: 'Privacy policy',
46 save: 'Save choices',
47}
48
49/**
50 * Single rem font-size on the module root; everything inside uses `em` so it scales together.
51 * Slightly smaller on narrow viewports so the sheet doesn’t feel oversized.
52 */
53const emRoot = 'text-[0.875rem] md:text-[1rem]'
54
55/** One rem value: entire switch scales ({@link CookieToggle} uses em for track, thumb, padding, travel). */
56const cookieToggleSizeRem = 0.5
57
58/** Typography (em relative to {@link emRoot}) */
59const tTitle =
60 'font-condensed text-[1.25em] leading-tight tracking-[0.01em] text-black'
61const tBody = 'text-[0.675em] leading-relaxed text-dark-gray'
62const tLink =
63 'text-[0.875em] font-medium text-black underline decoration-dark-gray/45 underline-offset-[0.15em] transition-colors hover:decoration-black'
64const tLabel =
65 'font-condensed text-[0.875em] font-bold uppercase tracking-[0.04em] text-black'
66
67const btnPrimary =
68 'inline-flex min-h-[3em] w-full shrink-0 items-center justify-center rounded-[0.5em] bg-black px-[1.5em] py-[0.75em] font-condensed text-[0.875em] font-bold uppercase tracking-[0.04em] text-pale-white shadow-eventcard transition-[background-color,box-shadow,transform] hover:bg-black/90 hover:shadow-md active:scale-[0.99] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black sm:w-auto sm:min-w-[10em] sm:px-[2em]'
69
70const btnSecondary =
71 'inline-flex min-h-[3em] w-full shrink-0 items-center justify-center rounded-[0.5em] border border-gray bg-pale-white px-[1.5em] py-[0.75em] font-condensed text-[0.875em] font-bold uppercase tracking-[0.04em] text-black transition-[background-color,border-color,transform] hover:border-dark-gray/30 hover:bg-light-gray active:scale-[0.99] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black sm:w-auto sm:min-w-[10em] sm:px-[2em]'
72
73const btnGhost =
74 'inline-flex min-h-[3em] w-full items-center justify-center rounded-[0.5em] px-[1em] py-[0.75em] font-condensed text-[0.875em] font-bold uppercase tracking-[0.04em] text-black underline decoration-dark-gray/35 underline-offset-[0.2em] transition-colors hover:bg-black/5 hover:decoration-black sm:w-auto'
75
76const shell =
77 'border-gray fixed bottom-[calc(var(--grid-margin)+env(safe-area-inset-bottom))] left-[max(1rem,var(--grid-margin),env(safe-area-inset-left))] right-[max(1rem,var(--grid-margin),env(safe-area-inset-right))] z-[300] flex max-h-[calc(100dvh-var(--grid-margin)-env(safe-area-inset-top)-(var(--grid-margin)+env(safe-area-inset-bottom)))] flex-col overflow-hidden rounded-[1em] border bg-pale-white/95 shadow-[0_-8px_40px_rgba(0,0,0,0.1)] ring-1 ring-black/5 backdrop-blur-md backdrop-saturate-150 pb-[.2em] md:bottom-[var(--grid-margin)] md:left-auto md:max-h-[min(90dvh,calc(100dvh-2*var(--grid-margin)))] md:max-w-[min(26em,calc(100vw-2*var(--grid-margin)))] md:right-[var(--grid-margin)] md:pb-0'
78
79type CookieConsentProps = {
80 settings?: CookieConsentSettings | null
81}
82
83export default function CookieConsent(props: CookieConsentProps) {
84 const settings = () => props.settings
85
86 const serverRegion = createAsync(() => getCookieConsentRegionFromRequest(), {
87 deferStream: true,
88 })
89
90 const region = createMemo(() =>
91 normalizeCookieConsentRegion(
92 readCookieConsentRegionFromDocument() ??
93 serverRegion() ??
94 DEFAULT_COOKIE_CONSENT_REGION,
95 ),
96 )
97
98 const copy = createMemo(
99 (): CookieConsentRegionCopy =>
100 resolveCookieConsentCopy(settings() ?? null, region()),
101 )
102
103 const strategy = createMemo(
104 (): CookieStrategy => parseCookieStrategy(copy().cookieStrategy),
105 )
106
107 const [prefsOpen, setPrefsOpen] = createSignal(false)
108 const [functional, setFunctional] = createSignal(false)
109 const [analytics, setAnalytics] = createSignal(false)
110 const [marketing, setMarketing] = createSignal(false)
111
112 const [needsBanner, setNeedsBanner] = createSignal(false)
113 const [dismissed, setDismissed] = createSignal(false)
114 const isVisible = () => (!dismissed() && needsBanner()) || prefsOpen()
115
116 function dismiss() {
117 // Closing is not a consent decision: discard unsaved choices and keep
118 // the existing script permissions and stored consent unchanged.
119 setPrefsOpen(false)
120 setDismissed(true)
121 }
122
123 createEffect(() => {
124 if (settings() === undefined) {
125 return
126 }
127 copy()
128 region()
129 const strat = strategy()
130 const eff = getEffectiveConsentForScripts(readStoredConsent(), strat)
131 applyConsentToScripts(eff, settings()?.gtmContainerId ?? null)
132 setNeedsBanner(!readStoredConsent())
133 })
134
135 onMount(() => {
136 const onEscape = (event: KeyboardEvent) => {
137 if (event.key !== 'Escape' || !isVisible()) return
138 event.preventDefault()
139 dismiss()
140 }
141 document.addEventListener('keydown', onEscape)
142 onCleanup(() => document.removeEventListener('keydown', onEscape))
143
144 const open = () => {
145 const cur = readStoredConsent()
146 const defs = defaultNonEssentialToggles(strategy())
147 setFunctional(cur?.functional ?? defs.functional)
148 setAnalytics(cur?.analytics ?? defs.analytics)
149 setMarketing(cur?.marketing ?? defs.marketing)
150 setPrefsOpen(true)
151 }
152 window.addEventListener('cookie-consent:open', open)
153 onCleanup(() => window.removeEventListener('cookie-consent:open', open))
154
155 /**
156 * Capture-phase delegate so editors can wire any `<a href="#cookie-settings">` to reopen
157 * the preferences sheet. Match by trailing fragment so absolute URLs (Sanity link
158 * resolver) and bare fragments both work; ignore modified clicks (new tab, etc.).
159 */
160 const onLinkClick = (event: MouseEvent) => {
161 if (
162 event.button !== 0 ||
163 event.metaKey ||
164 event.ctrlKey ||
165 event.shiftKey ||
166 event.altKey
167 ) {
168 return
169 }
170 const target = event.target as Element | null
171 const anchor = target?.closest?.('a') as HTMLAnchorElement | null
172 if (!anchor) {
173 return
174 }
175 const raw = anchor.getAttribute('href') ?? ''
176 const text = anchor.textContent?.trim().toLowerCase() ?? ''
177
178 // Match by explicit URL, or fallback to exact text match if they messed up the CMS input
179 if (
180 !raw.includes('cookie-settings') &&
181 !raw.includes('cookie-management') &&
182 text !== 'cookie settings' &&
183 text !== 'cookie management' &&
184 text !== 'manage cookies'
185 ) {
186 return
187 }
188 event.preventDefault()
189 event.stopPropagation()
190 openCookiePreferences()
191 }
192 document.addEventListener('click', onLinkClick, true)
193 onCleanup(() => document.removeEventListener('click', onLinkClick, true))
194 })
195
196 function commit(
197 consent: Omit<StoredConsent, 'v' | 'decidedAt'> & { decidedAt?: number },
198 ) {
199 const saved = writeStoredConsent(consent)
200 applyConsentToScripts(saved, settings()?.gtmContainerId ?? null)
201 fireGtmEvent('consentUpdated', {})
202 setNeedsBanner(false)
203 setPrefsOpen(false)
204 }
205
206 function acceptAll() {
207 commit({
208 ...defaultConsent(),
209 functional: true,
210 analytics: true,
211 marketing: true,
212 })
213 }
214
215 function rejectNonEssential() {
216 commit({
217 ...defaultConsent(),
218 functional: false,
219 analytics: false,
220 marketing: false,
221 })
222 }
223
224 function savePreferences() {
225 commit({
226 functional: functional(),
227 analytics: analytics(),
228 marketing: marketing(),
229 })
230 }
231
232 function openPrefs() {
233 const cur = readStoredConsent()
234 const defs = defaultNonEssentialToggles(strategy())
235 setFunctional(cur?.functional ?? defs.functional)
236 setAnalytics(cur?.analytics ?? defs.analytics)
237 setMarketing(cur?.marketing ?? defs.marketing)
238 setPrefsOpen(true)
239 }
240
241 return (
242 <Show when={settings() !== undefined}>
243 <Show when={isVisible()}>
244 <div
245 class={`${shell} ${emRoot}`}
246 role="dialog"
247 aria-modal="true"
248 aria-label={copy().title ?? 'Cookie preferences'}
249 >
250 <div class="flex shrink-0 justify-end px-6 pt-4">
251 <button
252 type="button"
253 class="ts-label rounded-md px-4 py-3 text-black hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black"
254 aria-label="Close privacy preferences without accepting"
255 onClick={dismiss}
256 >
257 Close
258 </button>
259 </div>
260 <Show when={!prefsOpen()}>
261 <div class="flex shrink-0 flex-col gap-[1.25em] px-[1.125em] pt-[1.5em] pb-[0.75em] sm:gap-[0.875em] sm:px-[0.875em] sm:pt-[1.75em] sm:pb-[1em] md:px-[1.125em]">
262 <div class="flex flex-col gap-[0.75em]">
263 <p class={tTitle}>{copy().title ?? 'We value your privacy'}</p>
264 <Show when={copy().description}>
265 <p class={`${tBody} whitespace-pre-wrap`}>{copy().description}</p>
266 </Show>
267 </div>
268 <Show when={copy().privacyPolicyUrl}>
269 {(href) => (
270 <a
271 class={`inline-flex w-fit ${tLink}`}
272 href={String(href())}
273 target="_blank"
274 rel="noreferrer"
275 >
276 {copy().privacyPolicyLabel ?? D.privacy}
277 </a>
278 )}
279 </Show>
280 <div class="flex flex-col gap-[0.25em] sm:flex-row sm:flex-wrap sm:items-center sm:gap-[0.25em]">
281 <button type="button" class={btnPrimary} onClick={() => acceptAll()}>
282 {copy().acceptAllLabel ?? D.acceptAll}
283 </button>
284 <button
285 type="button"
286 class={btnSecondary}
287 onClick={() => rejectNonEssential()}
288 >
289 {copy().rejectNonEssentialLabel ?? D.reject}
290 </button>
291 <button type="button" class={btnGhost} onClick={() => openPrefs()}>
292 {copy().managePreferencesLabel ?? D.manage}
293 </button>
294 </div>
295 </div>
296 </Show>
297
298 <Show when={prefsOpen()}>
299 <div
300 class="cookie-consent-scroll-pane flex min-h-0 flex-1 touch-pan-y flex-col gap-[1em] overflow-y-auto overscroll-contain px-[1.125em] py-[1.25em] sm:gap-[1.25em] sm:px-[1.25em] sm:py-[1.5em] md:px-[1.75em]"
301 data-lenis-prevent
302 >
303 <div>
304 <p class={tTitle}>Cookie categories</p>
305 <p class={`${tBody} mt-[0.5em]`}>
306 Turn categories on or off, then save your choices.
307 </p>
308 </div>
309
310 <div class="border-gray/60 bg-light-gray/40 rounded-[0.5em] border p-[0.75em] sm:p-[1em]">
311 <p class={tLabel}>Essential</p>
312 <p class={`${tBody} mt-[0.5em]`}>
313 Required for the site to work. Always on.
314 </p>
315 </div>
316
317 <div class="flex flex-col gap-3">
318 <CategoryRow
319 title={copy().functionalCategoryLabel ?? 'Functional'}
320 description={copy().functionalCategoryDescription}
321 checked={functional()}
322 onChange={setFunctional}
323 />
324 <CategoryRow
325 title={copy().analyticsCategoryLabel ?? 'Analytics'}
326 description={copy().analyticsCategoryDescription}
327 checked={analytics()}
328 onChange={setAnalytics}
329 />
330 <CategoryRow
331 title={copy().marketingCategoryLabel ?? 'Marketing'}
332 description={copy().marketingCategoryDescription}
333 checked={marketing()}
334 onChange={setMarketing}
335 />
336 </div>
337
338 <div class="flex flex-col gap-[0.75em] pt-[0.375em] sm:flex-row sm:flex-wrap sm:items-center sm:gap-[0.75em]">
339 <button
340 type="button"
341 class={btnPrimary}
342 onClick={() => savePreferences()}
343 >
344 {copy().savePreferencesLabel ?? D.save}
345 </button>
346 <button
347 type="button"
348 class={btnGhost}
349 onClick={() => {
350 setPrefsOpen(false)
351 if (!readStoredConsent()) {
352 setNeedsBanner(true)
353 }
354 }}
355 >
356 {copy().cancelPreferencesLabel ?? 'Cancel'}
357 </button>
358 </div>
359 </div>
360 </Show>
361 </div>
362 </Show>
363 </Show>
364 )
365}
366
367function CategoryRow(props: {
368 title: string
369 description?: string
370 checked: boolean
371 onChange: (v: boolean) => void
372}) {
373 const id = createUniqueId()
374
375 return (
376 <div class="border-gray/70 bg-pale-white hover:border-dark-gray/35 hover:bg-light-gray/30 flex flex-col gap-[0.5em] rounded-[0.5em] border p-[0.75em] transition-[border-color,background-color] sm:p-[1em]">
377 <div class="flex items-start justify-between gap-[1em]">
378 <label class="min-w-0 flex-1 cursor-pointer pr-[0.5em]" for={id}>
379 <span class={tLabel}>{props.title}</span>
380 </label>
381 <CookieToggle
382 id={id}
383 checked={props.checked}
384 onChange={props.onChange}
385 aria-label={props.title}
386 sizeRem={cookieToggleSizeRem}
387 />
388 </div>
389 <Show when={props.description}>
390 <p class={tBody}>{props.description}</p>
391 </Show>
392 </div>
393 )
394}
395