apps/web/src/lib/cookie-consent/consent-state.ts

1export type CookieCategoryId =
2	| 'essential'
3	| 'functional'
4	| 'analytics'
5	| 'marketing'
6
7export type StoredConsent = {
8	v: 2
9	functional: boolean
10	analytics: boolean
11	marketing: boolean
12	decidedAt: number
13}
14
15const STORAGE_KEY = 'cc-consent'
16export const CONSENT_CHANGED_EVENT = 'cookie-consent:changed'
17
18export const defaultConsent = (): Omit<StoredConsent, 'decidedAt'> => ({
19	v: 2,
20	functional: false,
21	analytics: false,
22	marketing: false,
23})
24
25function isStoredConsent(x: unknown): x is StoredConsent {
26	if (!x || typeof x !== 'object') {
27		return false
28	}
29	const o = x as Record<string, unknown>
30	return (
31		o.v === 2 &&
32		typeof o.functional === 'boolean' &&
33		typeof o.analytics === 'boolean' &&
34		typeof o.marketing === 'boolean' &&
35		typeof o.decidedAt === 'number'
36	)
37}
38
39export function readStoredConsent(): StoredConsent | null {
40	if (typeof localStorage === 'undefined') {
41		return null
42	}
43	try {
44		const raw = localStorage.getItem(STORAGE_KEY)
45		if (!raw) {
46			return null
47		}
48		const parsed: unknown = JSON.parse(raw)
49		return isStoredConsent(parsed) ? parsed : null
50	} catch {
51		return null
52	}
53}
54
55export function writeStoredConsent(
56	partial: Omit<StoredConsent, 'v' | 'decidedAt'> & { decidedAt?: number },
57): StoredConsent {
58	const entry: StoredConsent = {
59		v: 2,
60		functional: partial.functional,
61		analytics: partial.analytics,
62		marketing: partial.marketing,
63		decidedAt: partial.decidedAt ?? Date.now(),
64	}
65	localStorage.setItem(STORAGE_KEY, JSON.stringify(entry))
66	if (typeof window !== 'undefined')
67		window.dispatchEvent(new Event(CONSENT_CHANGED_EVENT))
68	return entry
69}
70
71export function clearStoredConsent(): void {
72	localStorage.removeItem(STORAGE_KEY)
73	if (typeof window !== 'undefined')
74		window.dispatchEvent(new Event(CONSENT_CHANGED_EVENT))
75}
76
77export function categoryAllowed(
78	category: string | null | undefined,
79	consent: StoredConsent | null,
80): boolean {
81	const c = (category ?? '').toLowerCase()
82	if (c === 'essential' || c === '') {
83		return true
84	}
85	if (!consent) {
86		return false
87	}
88	if (c === 'functional') {
89		return consent.functional
90	}
91	if (c === 'analytics') {
92		return consent.analytics
93	}
94	if (c === 'marketing') {
95		return consent.marketing
96	}
97	return false
98}
99
100/** Explicit saved functional consent only, including in opt-out regions. */
101export function observeFunctionalConsent(onChange: (allowed: boolean) => void) {
102	const refresh = () =>
103		onChange(categoryAllowed('functional', readStoredConsent()))
104	const onStorage = (event: StorageEvent) => {
105		if (event.key === STORAGE_KEY || event.key === null) refresh()
106	}
107	window.addEventListener(CONSENT_CHANGED_EVENT, refresh)
108	window.addEventListener('storage', onStorage)
109	window.addEventListener('focus', refresh)
110	refresh()
111	return () => {
112		window.removeEventListener(CONSENT_CHANGED_EVENT, refresh)
113		window.removeEventListener('storage', onStorage)
114		window.removeEventListener('focus', refresh)
115	}
116}
117