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