168 lines
5.1 KiB
TypeScript
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
|
|
}
|