Files
Momento/memento-note/lib/consent/cookie-consent.ts
Antigravity a623454347
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped
perf: memo GridCard, fuse save fns, fix slash tab active color
2026-06-14 14:06:05 +00:00

168 lines
5.1 KiB
TypeScript

export const CONSENT_VERSION = 1 as const
export const CONSENT_STORAGE_KEY = 'memento-consent-v1'
export const CONSENT_COOKIE_NAME = 'memento-cookie-consent'
const CONSENT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
export type ConsentRecord = {
version: typeof CONSENT_VERSION
necessary: true
analytics: boolean
marketing: boolean
updatedAt: string
}
export const CONSENT_CHANGE_EVENT = 'memento-consent-change'
export const OPEN_COOKIE_PREFERENCES_EVENT = 'memento-open-cookie-preferences'
function isBrowser(): boolean {
return typeof window !== 'undefined'
}
function parseRecord(raw: unknown): ConsentRecord | null {
if (!raw || typeof raw !== 'object') return null
const o = raw as Partial<ConsentRecord>
if (o.version !== CONSENT_VERSION) return null
if (o.necessary !== true) return null
if (typeof o.analytics !== 'boolean' || typeof o.marketing !== 'boolean') return null
if (typeof o.updatedAt !== 'string') return null
return {
version: CONSENT_VERSION,
necessary: true,
analytics: o.analytics,
marketing: o.marketing,
updatedAt: o.updatedAt,
}
}
function readCookie(): ConsentRecord | null {
if (!isBrowser()) return null
const match = document.cookie
.split(';')
.map((s) => s.trim())
.find((s) => s.startsWith(`${CONSENT_COOKIE_NAME}=`))
if (!match) return null
try {
const encoded = match.slice(CONSENT_COOKIE_NAME.length + 1)
const json = decodeURIComponent(atob(encoded))
return parseRecord(JSON.parse(json))
} catch {
return null
}
}
function writeCookie(record: ConsentRecord): void {
if (!isBrowser()) return
const encoded = btoa(encodeURIComponent(JSON.stringify(record)))
document.cookie = `${CONSENT_COOKIE_NAME}=${encoded};path=/;max-age=${CONSENT_COOKIE_MAX_AGE};samesite=lax`
}
export function getConsent(): ConsentRecord | null {
if (!isBrowser()) return null
try {
const fromStorage = localStorage.getItem(CONSENT_STORAGE_KEY)
if (fromStorage) {
const parsed = parseRecord(JSON.parse(fromStorage))
if (parsed) return parsed
}
} catch {
// ignore corrupt storage
}
return readCookie()
}
export function hasConsentChoice(): boolean {
return getConsent() !== null
}
export function hasAnalyticsConsent(): boolean {
return getConsent()?.analytics === true
}
export function hasMarketingConsent(): boolean {
return getConsent()?.marketing === true
}
export function setConsent(partial: Pick<ConsentRecord, 'analytics' | 'marketing'>): ConsentRecord {
const record: ConsentRecord = {
version: CONSENT_VERSION,
necessary: true,
analytics: partial.analytics,
marketing: partial.marketing,
updatedAt: new Date().toISOString(),
}
if (isBrowser()) {
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(record))
writeCookie(record)
window.dispatchEvent(new CustomEvent(CONSENT_CHANGE_EVENT, { detail: record }))
}
return record
}
export function acceptEssentialsOnly(): ConsentRecord {
return setConsent({ analytics: false, marketing: false })
}
export function acceptAllOptional(): ConsentRecord {
return setConsent({ analytics: true, marketing: false })
}
export function openCookiePreferences(): void {
if (!isBrowser()) return
window.dispatchEvent(new CustomEvent(OPEN_COOKIE_PREFERENCES_EVENT))
}
/**
* Save consent locally and sync to database for authenticated users.
* This is the preferred method for updating consent from UI components.
*
* Note: For server-side sync, import and call saveCookieConsent action from your component.
*/
export function saveConsentWithSync(analytics: boolean, marketing: boolean = false): ConsentRecord {
const record = setConsent({ analytics, marketing })
// Trigger server-side sync in the background
// The import() is dynamic to avoid "use server" import issues in client code
if (isBrowser()) {
import('@/app/actions/cookie-consent').then(({ saveCookieConsent }) => {
saveCookieConsent(record).catch((err) => {
console.warn('[saveConsentWithSync] Server sync failed (local consent saved):', err)
})
})
}
return record
}
/**
* Load consent preferring DB value when local storage is empty.
* For authenticated users on a new device, this syncs their existing consent.
*
* Returns the consent record that should be used, or null if no preference exists.
*/
export async function loadConsentWithDBSync(): Promise<ConsentRecord | null> {
// First check local storage (fast, always available)
const local = getConsent()
if (local) {
return local // User already has local preference, use it
}
// No local preference — try to load from DB for authenticated users
if (isBrowser()) {
try {
const { getCookieConsentFromDB } = await import('@/app/actions/cookie-consent')
const dbConsent = await getCookieConsentFromDB()
if (dbConsent) {
// Sync DB value to local storage for future use
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(dbConsent))
writeCookie(dbConsent)
return dbConsent
}
} catch (error) {
console.warn('[loadConsentWithDBSync] Failed to load from DB:', error)
}
}
return null // No preference anywhere
}