// CookieConsentBanner.jsx — v0.13.0 / roadmap item #11 / SPEC §14.5. // // A non-modal bottom-of-page banner that asks the user once which // categories of cookies they accept. The framework's strictly-necessary // cookies (session, signed payloads) are always on; the user can opt in // or out of analytics (which gates the §13 SDK landing in v0.15.0) and // "other" (third-party embeds, social widgets if a deployment adds any). // // Visible until the user makes a choice. Hides itself once the choice // is recorded. The /settings/notifications "Privacy & cookies" tab // surfaces the current choice and re-opens the banner via `forceOpen`. import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js' import { getCookieConsent, setCookieConsent } from '../api.js' const CATEGORIES = [ { key: 'essential-only', label: 'Essential only', description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)', flags: { analytics: false, other: false }, }, { key: 'essential-analytics', label: 'Essential + analytics', description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.', flags: { analytics: true, other: false }, }, { key: 'essential-analytics-other', label: 'Essential + analytics + other', description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.', flags: { analytics: true, other: true }, }, ] function selectionKeyFor(consent) { if (consent.analytics && consent.other) return 'essential-analytics-other' if (consent.analytics && !consent.other) return 'essential-analytics' return 'essential-only' } export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) { const [open, setOpen] = useState(() => forceOpen || !hasChosen()) const [choice, setChoice] = useState(() => selectionKeyFor(getConsent())) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) // When forceOpen flips (settings "Change" affordance), re-render the // banner and pre-select the user's current choice. useEffect(() => { if (forceOpen) { setOpen(true) setChoice(selectionKeyFor(getConsent())) } }, [forceOpen]) // Server-side hydrate for authenticated viewers per the v0.13.0 // precedence rule: a server row overrides local; absent server row, // upload the local choice. useEffect(() => { if (!viewer?.user_id) return let cancelled = false getCookieConsent() .then(record => { if (cancelled) return if (record.recorded_at) { // Server is authoritative — adopt + hide the banner unless // the settings page forced it open. hydrateFromServer(record) setChoice(selectionKeyFor(record)) if (!forceOpen) setOpen(false) } else if (hasChosen()) { // Local has a choice the server doesn't know about yet — push. const local = getConsent() setCookieConsent({ analytics: local.analytics, other: local.other }) .then(r => hydrateFromServer(r)) .catch(() => {}) } }) .catch(() => { // Network or auth error — leave the local-only path in place. }) return () => { cancelled = true } }, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps if (!open) return null async function save() { setSaving(true) setError(null) const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0] try { setConsent(picked.flags) if (viewer?.user_id) { // Best-effort server persistence. A failure here doesn't // invalidate the local choice; the banner still hides because // the user expressed their preference. The server can catch up // on the next sign-in via the hydrate path above. try { const r = await setCookieConsent(picked.flags) hydrateFromServer(r) } catch (e) { setError(`Saved locally; server sync failed (${e.message}).`) } } setOpen(false) onClosed?.() } finally { setSaving(false) } } return (

Cookies & privacy

This site uses cookies. Essential cookies keep you signed in and protect your submissions; analytics and other cookies are optional. Choose what you allow — you can change this any time from Settings → Privacy & cookies.

Cookie categories {CATEGORIES.map(c => ( ))}

Cookies policy · Privacy policy

{error &&

{error}

}
) }