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