'use client' import { cn } from '../../lib/cn' import { Button } from '../Button' import { Surface } from '../Surface' import { Text } from '../Text' import { Switch } from '../Switch' export interface ConsentCategory { id: string label: string description: string /** Always on, and honestly explained rather than silently forced. */ required?: boolean /** Who receives the data. Naming them is most of what consent means. */ recipients?: string[] } export interface ConsentManagerProps { categories: ConsentCategory[] /** Which categories are on. Required ones are treated as on regardless. */ value: Record onChange: (value: Record) => void /** Save the current selection. */ onSave: (value: Record) => void /** Accessible name. */ label: string /** Copy above the list. */ description?: string /** Merged last, so it wins. */ className?: string } /** * Granular consent, with rejecting everything exactly as easy as accepting it. * * The two bulk buttons are the same size, the same shape and side by side. * Every dark pattern in this space is built the other way — a bright Accept all * beside a grey link to a settings page — and a consent choice that is * cheaper to give than to withhold is not a choice, it is a toll. * * Required categories are shown, switched on and disabled, with the reason * beside them. Hiding them would be simpler and would also be the moment the * screen stops being an honest description of what happens. * * Recipients are named per category. "Analytics" is not informed consent; * "Analytics — shared with Fathom" is the same sentence with the part that * matters left in. */ export function ConsentManager({ categories, value, onChange, onSave, label, description, className, }: ConsentManagerProps) { const set = (next: Record) => onChange(next) const all = (on: boolean) => { const next: Record = {} for (const category of categories) next[category.id] = category.required ? true : on set(next) onSave(next) } const optional = categories.filter((category) => !category.required) const chosen = optional.filter((category) => value[category.id]).length return ( {description && ( {description} )}
    {categories.map((category) => { const on = category.required || Boolean(value[category.id]) return (
  • {category.label} {category.description} {category.recipients && category.recipients.length > 0 && ( Shared with {category.recipients.join(', ')} )} {category.required && ( Always on — the service does not work without it. )} set({ ...value, [category.id]: event.target.checked })} />
  • ) })}
{/* Equal weight, side by side. Anything else is a toll, not a choice. */}
{chosen} of {optional.length} optional {optional.length === 1 ? 'category' : 'categories'} on.
) }