Einstellungen und Einwilligungen
Die Seite, auf die Menschen aus jeder E-Mail oder ihrem Konto zurückkommen. Themen und Häufigkeit mit Speicherleiste, die bei Änderungen erscheint, Abmelden mit sanfterer Option zuerst, Themen gegen Kanäle und Anfragen zu den eigenen Daten.
Eine von 3 Components in Adresse, Zahlung und Einwilligung
Variants
Details
Was Einstellungen und Einwilligungen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Adresse, Zahlung und Einwilligung
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/consent-preferences.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 49 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ConsentPreferencesProps aus components/ui/consent-preferences.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalConsentPreferencesVariantemail-topicsunsubscribechannel-matrixprivacy-requests
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeemailoptionalstringThe address the settings belong to, shown so people know which inbox
topicsoptionalConsentTopic[]channelsoptionalConsentChannel[]frequenciesoptionalstring[]email-topics: frequencies to choose from
answerTimeoptionalstringprivacy-requests: who answers and how fast, e.g. "within 30 days"
onSaveoptional(data: Record<string, unknown>) => void
Eingebaute Texte
Diese Texte schreibt Einstellungen und Einwilligungen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 49 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| for | Englisch Settings for {e} | Deutsch Einstellungen für {e} |
| frequency | Englisch How often? | Deutsch Wie oft? |
| pause | Englisch Pause all emails for 30 days | Deutsch Alle E-Mails 30 Tage pausieren |
| pauseText | Englisch We keep your choices and start again after the break. | Deutsch Wir behalten deine Auswahl und starten nach der Pause wieder. |
| unsaved | Englisch You changed your settings | Deutsch Du hast Einstellungen geändert |
| save | Englisch Save settings | Deutsch Einstellungen speichern |
| saved | Englisch Saved. Your choices apply from the next email. | Deutsch Gespeichert. Deine Auswahl gilt ab der nächsten E-Mail. |
| required | Englisch Always on | Deutsch Immer an |
| requiredText | Englisch Needed for your account | Deutsch Für dein Konto nötig |
| fewer | Englisch Fewer emails | Deutsch Weniger E-Mails |
| fewerText | Englisch One summary a month instead of every news | Deutsch Eine Zusammenfassung im Monat statt jeder Neuigkeit |
| important | Englisch Only what matters | Deutsch Nur Wichtiges |
| importantText | Englisch Orders, invoices and security, nothing else | Deutsch Bestellungen, Rechnungen und Sicherheit, sonst nichts |
| none | Englisch Unsubscribe from all | Deutsch Von allem abmelden |
| noneText | Englisch You can still sign in and see your orders | Deutsch Du kannst dich weiter anmelden und Bestellungen sehen |
| why | Englisch May we ask why? | Deutsch Dürfen wir fragen, warum? |
| openCorrect | Englisch Correct | Deutsch Berichtigen |
| optional | Englisch Optional | Deutsch Optional |
| openDelete | Englisch Delete | Deutsch Löschen |
| confirm | Englisch Confirm | Deutsch Bestätigen |
| doneFewer | Englisch From now on you get one summary a month | Deutsch Ab jetzt bekommst du eine Zusammenfassung im Monat |
| doneImportant | Englisch From now on only what matters | Deutsch Ab jetzt nur noch Wichtiges |
| doneNone | Englisch You are unsubscribed | Deutsch Du bist abgemeldet |
| doneText | Englisch The change applies within 24 hours. A confirmation is on its way to {e}. | Deutsch Die Änderung gilt innerhalb von 24 Stunden. Eine Bestätigung ist unterwegs an {e}. |
| undo | Englisch Undo and subscribe again | Deutsch Rückgängig machen und wieder anmelden |
| topic | Englisch Topic | Deutsch Thema |
| channelOn | Englisch {t} by {c} | Deutsch {t} per {c} |
| allOn | Englisch All on | Deutsch Alle an |
| allOff | Englisch All off | Deutsch Alle aus |
| copy | Englisch Get a copy of my data | Deutsch Kopie meiner Daten anfordern |
| copyText | Englisch Everything we store about you, as a file you can open anywhere. | Deutsch Alles, was wir über dich speichern, als Datei, die du überall öffnen kannst. |
| copyDone | Englisch We are preparing your file. You get a download link by email. | Deutsch Wir bereiten deine Datei vor. Den Link zum Herunterladen bekommst du per E-Mail. |
| correct | Englisch Correct my data | Deutsch Meine Daten berichtigen |
| correctText | Englisch Tell us what is wrong, we fix it and confirm. | Deutsch Sag uns, was nicht stimmt, wir korrigieren es und bestätigen. |
| correctLabel | Englisch What should we correct? | Deutsch Was sollen wir berichtigen? |
| correctNeeded | Englisch Please tell us what is wrong, so we know what to fix. | Deutsch Bitte schreib, was nicht stimmt, damit wir wissen, was wir korrigieren. |
| correctDone | Englisch Thank you, we correct it and confirm by email. | Deutsch Danke, wir korrigieren es und bestätigen per E-Mail. |
| object | Englisch Stop using my data for marketing | Deutsch Meine Daten nicht mehr für Werbung nutzen |
| objectText | Englisch No more offers or personal suggestions, your account stays. | Deutsch Keine Angebote und persönlichen Vorschläge mehr, dein Konto bleibt. |
| objectDone | Englisch Done. We no longer use your data for marketing. | Deutsch Erledigt. Wir nutzen deine Daten nicht mehr für Werbung. |
| del | Englisch Delete my account | Deutsch Mein Konto löschen |
| delText | Englisch Your account and data go for good. Invoices stay as long as the law asks. | Deutsch Konto und Daten verschwinden endgültig. Rechnungen bleiben, solange das Gesetz es verlangt. |
| delConfirm | Englisch Type {w} to confirm | Deutsch Tippe {w} zur Bestätigung |
| delWord | Englisch DELETE | Deutsch LÖSCHEN |
| delNeeded | Englisch Please type {w} in capitals, so nothing is deleted by accident. | Deutsch Bitte tippe {w} in Großbuchstaben, damit nichts aus Versehen gelöscht wird. |
| delDone | Englisch Your account will be deleted in 14 days. Until then you can change your mind in the email we sent. | Deutsch Dein Konto wird in 14 Tagen gelöscht. Bis dahin kannst du es dir über die E-Mail noch anders überlegen. |
| request | Englisch Send request | Deutsch Anfrage senden |
| answer | Englisch We answer {t} | Deutsch Wir antworten {t} |
| sent | Englisch Request sent | Deutsch Anfrage gesendet |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Einstellungen und Einwilligungen reagiert selbst auf die Tasten Entf.
- Setzt aria-controls, aria-expanded, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 628 Zeilen
'use client';
import { useId, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { DoneMark, FlowHead, Unfold } from '@/components/ui/form-flow';
/*
* What a person allows: which emails, on which channel, how often, and what
* happens with their data. Cookie consent lives in popups, this is the page
* people come back to, from the link in every email or from their account.
*
* email-topics topics as switches, a frequency, a pause, a save bar that appears on change
* unsubscribe fewer emails, only important ones or none, with a way back
* channel-matrix topics against channels (email, text, push, post), cards on phones
* privacy-requests copy, correct, object or delete, each a request with a clear outcome
*/
export type ConsentPreferencesVariant = 'email-topics' | 'unsubscribe' | 'channel-matrix' | 'privacy-requests';
export interface ConsentTopic {
id: string;
title: string;
text?: string;
icon?: IconName;
/** On at the start */
on?: boolean;
/** Required messages, shown but not switchable (e.g. invoices) */
locked?: boolean;
/** channel-matrix: channels that are on, by channel id */
channels?: string[];
}
export interface ConsentChannel {
id: string;
label: string;
icon?: IconName;
}
export interface ConsentPreferencesProps {
variant?: ConsentPreferencesVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** The address the settings belong to, shown so people know which inbox */
email?: string;
topics?: ConsentTopic[];
channels?: ConsentChannel[];
/** email-topics: frequencies to choose from */
frequencies?: string[];
/** privacy-requests: who answers and how fast, e.g. "within 30 days" */
answerTime?: string;
onSave?: (data: Record<string, unknown>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
for: 'Settings for {e}',
frequency: 'How often?',
pause: 'Pause all emails for 30 days',
pauseText: 'We keep your choices and start again after the break.',
unsaved: 'You changed your settings',
save: 'Save settings',
saved: 'Saved. Your choices apply from the next email.',
required: 'Always on',
requiredText: 'Needed for your account',
fewer: 'Fewer emails',
fewerText: 'One summary a month instead of every news',
important: 'Only what matters',
importantText: 'Orders, invoices and security, nothing else',
none: 'Unsubscribe from all',
noneText: 'You can still sign in and see your orders',
why: 'May we ask why?',
openCorrect: 'Correct',
optional: 'Optional',
openDelete: 'Delete',
confirm: 'Confirm',
doneFewer: 'From now on you get one summary a month',
doneImportant: 'From now on only what matters',
doneNone: 'You are unsubscribed',
doneText: 'The change applies within 24 hours. A confirmation is on its way to {e}.',
undo: 'Undo and subscribe again',
topic: 'Topic',
channelOn: '{t} by {c}',
allOn: 'All on',
allOff: 'All off',
copy: 'Get a copy of my data',
copyText: 'Everything we store about you, as a file you can open anywhere.',
copyDone: 'We are preparing your file. You get a download link by email.',
correct: 'Correct my data',
correctText: 'Tell us what is wrong, we fix it and confirm.',
correctLabel: 'What should we correct?',
correctNeeded: 'Please tell us what is wrong, so we know what to fix.',
correctDone: 'Thank you, we correct it and confirm by email.',
object: 'Stop using my data for marketing',
objectText: 'No more offers or personal suggestions, your account stays.',
objectDone: 'Done. We no longer use your data for marketing.',
del: 'Delete my account',
delText: 'Your account and data go for good. Invoices stay as long as the law asks.',
delConfirm: 'Type {w} to confirm',
delWord: 'DELETE',
delNeeded: 'Please type {w} in capitals, so nothing is deleted by accident.',
delDone: 'Your account will be deleted in 14 days. Until then you can change your mind in the email we sent.',
request: 'Send request',
answer: 'We answer {t}',
sent: 'Request sent',
},
de: {
for: 'Einstellungen für {e}',
frequency: 'Wie oft?',
pause: 'Alle E-Mails 30 Tage pausieren',
pauseText: 'Wir behalten deine Auswahl und starten nach der Pause wieder.',
unsaved: 'Du hast Einstellungen geändert',
save: 'Einstellungen speichern',
saved: 'Gespeichert. Deine Auswahl gilt ab der nächsten E-Mail.',
required: 'Immer an',
requiredText: 'Für dein Konto nötig',
fewer: 'Weniger E-Mails',
fewerText: 'Eine Zusammenfassung im Monat statt jeder Neuigkeit',
important: 'Nur Wichtiges',
importantText: 'Bestellungen, Rechnungen und Sicherheit, sonst nichts',
none: 'Von allem abmelden',
noneText: 'Du kannst dich weiter anmelden und Bestellungen sehen',
why: 'Dürfen wir fragen, warum?',
openCorrect: 'Berichtigen',
optional: 'Optional',
openDelete: 'Löschen',
confirm: 'Bestätigen',
doneFewer: 'Ab jetzt bekommst du eine Zusammenfassung im Monat',
doneImportant: 'Ab jetzt nur noch Wichtiges',
doneNone: 'Du bist abgemeldet',
doneText: 'Die Änderung gilt innerhalb von 24 Stunden. Eine Bestätigung ist unterwegs an {e}.',
undo: 'Rückgängig machen und wieder anmelden',
topic: 'Thema',
channelOn: '{t} per {c}',
allOn: 'Alle an',
allOff: 'Alle aus',
copy: 'Kopie meiner Daten anfordern',
copyText: 'Alles, was wir über dich speichern, als Datei, die du überall öffnen kannst.',
copyDone: 'Wir bereiten deine Datei vor. Den Link zum Herunterladen bekommst du per E-Mail.',
correct: 'Meine Daten berichtigen',
correctText: 'Sag uns, was nicht stimmt, wir korrigieren es und bestätigen.',
correctLabel: 'Was sollen wir berichtigen?',
correctNeeded: 'Bitte schreib, was nicht stimmt, damit wir wissen, was wir korrigieren.',
correctDone: 'Danke, wir korrigieren es und bestätigen per E-Mail.',
object: 'Meine Daten nicht mehr für Werbung nutzen',
objectText: 'Keine Angebote und persönlichen Vorschläge mehr, dein Konto bleibt.',
objectDone: 'Erledigt. Wir nutzen deine Daten nicht mehr für Werbung.',
del: 'Mein Konto löschen',
delText: 'Konto und Daten verschwinden endgültig. Rechnungen bleiben, solange das Gesetz es verlangt.',
delConfirm: 'Tippe {w} zur Bestätigung',
delWord: 'LÖSCHEN',
delNeeded: 'Bitte tippe {w} in Großbuchstaben, damit nichts aus Versehen gelöscht wird.',
delDone: 'Dein Konto wird in 14 Tagen gelöscht. Bis dahin kannst du es dir über die E-Mail noch anders überlegen.',
request: 'Anfrage senden',
answer: 'Wir antworten {t}',
sent: 'Anfrage gesendet',
},
};
type Texts = (typeof TEXTS)['en'];
const REASONS = { en: ['Too many emails', 'Not relevant for me', 'I never signed up', 'Something else'], de: ['Zu viele E-Mails', 'Für mich nicht relevant', 'Nie angemeldet', 'Etwas anderes'] };
function Address({ email, t }: { email?: string; t: Texts }) {
if (!email) return null;
return (
<p className="flex items-center gap-2 text-[13.5px] text-muted-foreground">
<Icon name="mail" size={15} className="shrink-0" />
{t.for.replace('{e}', email)}
</p>
);
}
/* ------------------------------------------------------------------ */
/* email-topics */
/* ------------------------------------------------------------------ */
function EmailTopics({ p, t }: { p: ConsentPreferencesProps; t: Texts }) {
const topics = p.topics ?? [];
const freqs = p.frequencies ?? [];
const start = { on: Object.fromEntries(topics.map((x) => [x.id, Boolean(x.on || x.locked)])), freq: freqs[0] ?? '', pause: false };
const [on, setOn] = useState<Record<string, boolean>>(start.on);
const [freq, setFreq] = useState(start.freq);
const [pause, setPause] = useState(false);
const [base, setBase] = useState(JSON.stringify(start));
const [savedNote, setSavedNote] = useState(false);
const id = useId();
const dirty = JSON.stringify({ on, freq, pause }) !== base;
return (
<Reveal effect="rise" className="mx-auto max-w-[44rem]">
<div className="kit-card overflow-hidden">
<div className="border-b border-border px-5 py-4 sm:px-6">
<Address email={p.email} t={t} />
</div>
<Stagger step={60} as="ul" className={cn('divide-y divide-border transition-opacity duration-500', pause && 'opacity-50')}>
{topics.map((x) => (
<li key={x.id} className="px-5 py-4 sm:px-6">
{x.locked ? (
<div className="flex items-center gap-3.5">
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{x.title}</span>
<span className="block text-[13px] text-muted-foreground">{x.text ?? t.requiredText}</span>
</span>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
<Icon name="lock" size={12} />
{t.required}
</span>
</div>
) : (
<Switch label={x.title} description={x.text} icon={x.icon} variant="default" checked={on[x.id]} disabled={pause} onCheckedChange={(v) => (setOn((o) => ({ ...o, [x.id]: v })), setSavedNote(false))} />
)}
</li>
))}
</Stagger>
{freqs.length > 0 && (
<fieldset className="border-t border-border px-5 py-5 sm:px-6">
<legend className="sr-only">{t.frequency}</legend>
<p aria-hidden className="text-sm font-medium text-foreground">
{t.frequency}
</p>
{/* A segmented choice with a sliding tile */}
<div className="relative mt-3 grid rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1" style={{ gridTemplateColumns: `repeat(${freqs.length}, minmax(0, 1fr))` }}>
<span aria-hidden className="absolute inset-y-1 start-1 rounded-md bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none" style={{ width: `calc(${Math.round((100 / freqs.length) * 1000) / 1000}% - ${Math.round((8 / freqs.length) * 100) / 100}px)`, translate: `calc(${Math.max(0, freqs.indexOf(freq))} * 100%)` }} />
{freqs.map((x, i) => (
<label key={x} htmlFor={`${id}-f${i}`} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center rounded-md px-2 text-center text-[13.5px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] sm:min-h-10">
<input id={`${id}-f${i}`} type="radio" name={`${id}-freq`} checked={freq === x} disabled={pause} onChange={() => (setFreq(x), setSavedNote(false))} className="sr-only" />
{x}
</label>
))}
</div>
</fieldset>
)}
<div className="border-t border-border bg-surface px-5 py-4 sm:px-6">
<Switch label={t.pause} description={t.pauseText} icon="pause" variant="default" checked={pause} onCheckedChange={(v) => (setPause(v), setSavedNote(false))} />
</div>
</div>
{/* The save bar rises in only when something changed and stays at the bottom of the screen */}
<div className="sticky bottom-3 z-20 mt-4" aria-live="polite">
{dirty ? (
<div className="kit-rise flex items-center justify-between gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--card)_90%,transparent)] p-2 ps-4 shadow-[var(--shadow-lg)] backdrop-blur-md">
<span className="flex min-w-0 items-center gap-2 text-[13.5px] text-foreground">
<span className="size-2 shrink-0 rounded-full bg-primary" />
<span className="truncate">{t.unsaved}</span>
</span>
<Button
size="sm"
icon="check"
onClick={() => {
p.onSave?.({ topics: on, frequency: freq, pause });
setBase(JSON.stringify({ on, freq, pause }));
setSavedNote(true);
}}
>
{t.save}
</Button>
</div>
) : savedNote ? (
<p role="status" className="kit-rise flex items-center justify-center gap-2 text-[13.5px] text-success-foreground">
<Icon name="check-circle" size={16} />
{t.saved}
</p>
) : null}
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* unsubscribe */
/* ------------------------------------------------------------------ */
function Unsubscribe({ p, t, reasons }: { p: ConsentPreferencesProps; t: Texts; reasons: string[] }) {
const id = useId();
const [choice, setChoice] = useState<'fewer' | 'important' | 'none'>('fewer');
const [reason, setReason] = useState('');
const [done, setDone] = useState(false);
const head = useRef<HTMLParagraphElement>(null);
const options: { v: typeof choice; icon: IconName; title: string; text: string }[] = [
{ v: 'fewer', icon: 'calendar', title: t.fewer, text: t.fewerText },
{ v: 'important', icon: 'shield', title: t.important, text: t.importantText },
{ v: 'none', icon: 'bell', title: t.none, text: t.noneText },
];
if (done)
return (
<div role="status" className="kit-card kit-rise mx-auto flex max-w-[38rem] flex-col items-center p-8 text-center sm:p-10">
<DoneMark size={56} />
<p ref={head} tabIndex={-1} className="mt-5 text-balance text-[21px] font-semibold tracking-[-0.02em] text-foreground outline-none">
{choice === 'fewer' ? t.doneFewer : choice === 'important' ? t.doneImportant : t.doneNone}
</p>
<p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{t.doneText.replace('{e}', p.email ?? '')}</p>
<Button variant="outline" iconLeft="undo" className="mt-6" onClick={() => setDone(false)}>
{t.undo}
</Button>
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
noValidate
className="kit-card grid gap-6 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
p.onSave?.({ choice, reason });
setDone(true);
requestAnimationFrame(() => head.current?.focus());
}}
>
<Address email={p.email} t={t} />
<div>
<fieldset className="min-w-0">
<legend className="sr-only">{t.fewer}</legend>
<Stagger step={80} as="div" className="grid gap-2.5">
{options.map((o) => {
const on = choice === o.v;
const destructive = o.v === 'none';
return (
<label key={o.v} className={cn('group/o flex cursor-pointer items-center gap-4 rounded-xl border bg-card p-4 transition-[border-color,box-shadow,background-color] duration-300 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', on ? (destructive ? 'border-[color-mix(in_oklab,var(--destructive)_55%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_5%,var(--card))]' : 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_50%,var(--border))]') : 'border-border hover:border-border-strong')}>
<input type="radio" name={`${id}-c`} checked={on} onChange={() => setChoice(o.v)} className="sr-only" />
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl transition-colors duration-300', on ? (destructive ? 'bg-[color-mix(in_oklab,var(--destructive)_14%,var(--card))] text-destructive' : 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]') : 'bg-surface text-muted-foreground')}>
<Icon name={o.icon} size={19} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[15px] font-medium text-foreground">{o.title}</span>
<span className="mt-0.5 block text-[13px] leading-snug text-muted-foreground">{o.text}</span>
</span>
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? (destructive ? 'border-destructive bg-destructive' : 'border-primary bg-primary') : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
<span className={cn('size-2 rounded-full bg-background transition-[scale] duration-300', on ? 'scale-100' : 'scale-0')} />
</span>
</label>
);
})}
</Stagger>
</fieldset>
{/* The reason opens only for a full unsubscribe, it is optional */}
<Unfold open={choice === 'none'}>
<div className="pt-6">
<fieldset className="min-w-0">
<legend className="text-sm font-medium text-foreground">
{t.why} <span className="text-xs font-normal text-subtle-foreground">{t.optional}</span>
</legend>
<div className="mt-3 flex flex-wrap gap-2">
{reasons.map((r) => (
<label key={r} className="flex min-h-11 cursor-pointer items-center rounded-lg border border-border bg-card px-3.5 text-[13.5px] text-foreground transition-[border-color,background-color] has-[:checked]:border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] has-[:checked]:bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] sm:min-h-10">
<input type="radio" name={`${id}-r`} checked={reason === r} onChange={() => setReason(r)} className="sr-only" />
{r}
</label>
))}
</div>
</fieldset>
</div>
</Unfold>
</div>
<Button type="submit" variant={choice === 'none' ? 'destructive' : 'primary'} icon="check" className="sm:justify-self-start max-sm:w-full">
{choice === 'none' ? t.none : t.confirm}
</Button>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* channel-matrix */
/* ------------------------------------------------------------------ */
/** One cell of the matrix: a real checkbox drawn by the kit, outside the parent so focus survives a toggle */
function Tick({ on, onToggle, label, locked }: { on: boolean; onToggle: () => void; label: string; locked?: boolean }) {
return (
<label className={cn('group/tick grid size-11 cursor-pointer place-items-center rounded-lg has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', locked && 'cursor-not-allowed')}>
<input type="checkbox" checked={on} disabled={locked} onChange={onToggle} aria-label={label} className="peer sr-only" />
<span aria-hidden className={cn('grid size-[22px] place-items-center rounded-md border transition-[background-color,border-color,scale] duration-300 ease-[cubic-bezier(0.34,1.6,0.64,1)]', on ? 'scale-105 border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))] bg-background group-hover/tick:border-foreground', locked && 'opacity-50')}>
<svg viewBox="0 0 24 24" width={14} height={14} fill="none" className={cn('text-primary-foreground transition-opacity', on ? 'opacity-100' : 'opacity-0')}>
<path d="m5 12.5 4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</label>
);
}
function ChannelMatrix({ p, t }: { p: ConsentPreferencesProps; t: Texts }) {
const topics = p.topics ?? [];
const channels = p.channels ?? [];
const start = Object.fromEntries(topics.map((x) => [x.id, x.channels ?? []]));
const [state, setState] = useState<Record<string, string[]>>(start);
const [base, setBase] = useState(JSON.stringify(start));
const [savedNote, setSavedNote] = useState(false);
const dirty = JSON.stringify(state) !== base;
const toggle = (topic: string, ch: string) => {
setSavedNote(false);
setState((s) => {
const cur = s[topic] ?? [];
return { ...s, [topic]: cur.includes(ch) ? cur.filter((x) => x !== ch) : [...cur, ch] };
});
};
const column = (ch: string, value: boolean) => {
setSavedNote(false);
setState((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, topics.find((x) => x.id === k)?.locked ? v : value ? [...new Set([...v, ch])] : v.filter((x) => x !== ch)])));
};
return (
<Reveal effect="rise" className="mx-auto max-w-[52rem]">
<div className="kit-card overflow-hidden">
<div className="border-b border-border px-5 py-4 sm:px-6">
<Address email={p.email} t={t} />
</div>
{/* Tablet and desktop: a table with a switch for each column */}
<table className="hidden w-full border-collapse sm:table">
<thead>
<tr className="border-b border-border bg-surface">
<th scope="col" className="px-6 py-3 text-start text-[12.5px] font-medium text-muted-foreground">
{t.topic}
</th>
{channels.map((c) => {
const allOn = topics.filter((x) => !x.locked).every((x) => (state[x.id] ?? []).includes(c.id));
return (
<th key={c.id} scope="col" className="w-24 px-2 py-3 text-center">
<span className="flex flex-col items-center gap-1">
<span className="flex items-center gap-1.5 text-[13px] font-medium text-foreground">
{c.icon && <Icon name={c.icon} size={15} className="text-muted-foreground" />}
{c.label}
</span>
<button type="button" onClick={() => column(c.id, !allOn)} className="min-h-11 rounded-md px-1.5 text-[11.5px] font-medium lg:min-h-8 text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
{allOn ? t.allOff : t.allOn}
</button>
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{topics.map((x) => (
<tr key={x.id} className="border-b border-border transition-colors last:border-0 hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]">
<th scope="row" className="px-6 py-3 text-start font-normal">
<span className="block text-[14.5px] font-medium text-foreground">{x.title}</span>
{x.text && <span className="block text-[13px] text-muted-foreground">{x.text}</span>}
</th>
{channels.map((c) => (
<td key={c.id} className="px-2 py-1 text-center">
<span className="inline-grid">
<Tick on={(state[x.id] ?? []).includes(c.id)} onToggle={() => toggle(x.id, c.id)} label={t.channelOn.replace('{t}', x.title).replace('{c}', c.label)} locked={x.locked} />
</span>
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Phones: one card per topic with the channels as a row of chips */}
<ul className="divide-y divide-border sm:hidden">
{topics.map((x) => (
<li key={x.id} className="px-5 py-4">
<p className="text-[15px] font-medium text-foreground">{x.title}</p>
{x.text && <p className="text-[13px] text-muted-foreground">{x.text}</p>}
<div className="mt-3 grid grid-cols-2 gap-2">
{channels.map((c) => {
const on = (state[x.id] ?? []).includes(c.id);
return (
<label key={c.id} className={cn('flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border px-3 text-[13.5px] transition-[border-color,background-color] has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-border text-muted-foreground', x.locked && 'cursor-not-allowed opacity-60')}>
<input type="checkbox" checked={on} disabled={x.locked} onChange={() => toggle(x.id, c.id)} aria-label={t.channelOn.replace('{t}', x.title).replace('{c}', c.label)} className="sr-only" />
{c.icon && <Icon name={c.icon} size={15} />}
{c.label}
{on && <Icon name="check" size={14} strokeWidth={2.4} className="ms-auto text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />}
</label>
);
})}
</div>
</li>
))}
</ul>
<div className="flex flex-col gap-3 border-t border-border bg-surface px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6" aria-live="polite">
<p className={cn('text-[13.5px]', savedNote ? 'text-success-foreground' : 'text-muted-foreground')}>{savedNote ? t.saved : dirty ? t.unsaved : ''}</p>
<Button
icon="check"
disabled={!dirty}
onClick={() => {
p.onSave?.({ channels: state });
setBase(JSON.stringify(state));
setSavedNote(true);
}}
className="max-sm:w-full"
>
{t.save}
</Button>
</div>
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* privacy-requests */
/* ------------------------------------------------------------------ */
type RequestKind = 'copy' | 'correct' | 'object' | 'delete';
function PrivacyRequests({ p, t }: { p: ConsentPreferencesProps; t: Texts }) {
const id = useId();
const [open, setOpen] = useState<RequestKind | null>(null);
const [sent, setSent] = useState<Partial<Record<RequestKind, boolean>>>({});
const [correction, setCorrection] = useState('');
const [word, setWord] = useState('');
const [error, setError] = useState('');
const items: { k: RequestKind; icon: IconName; title: string; text: string; done: string }[] = [
{ k: 'copy', icon: 'download', title: t.copy, text: t.copyText, done: t.copyDone },
{ k: 'correct', icon: 'edit', title: t.correct, text: t.correctText, done: t.correctDone },
{ k: 'object', icon: 'ban', title: t.object, text: t.objectText, done: t.objectDone },
{ k: 'delete', icon: 'trash', title: t.del, text: t.delText, done: t.delDone },
];
const send = (k: RequestKind) => {
if (k === 'correct' && !correction.trim()) {
setError(t.correctNeeded);
requestAnimationFrame(() => document.getElementById(`${id}-correct`)?.focus());
return;
}
if (k === 'delete' && word.trim() !== t.delWord) {
setError(t.delNeeded.replace('{w}', t.delWord));
requestAnimationFrame(() => document.getElementById(`${id}-word`)?.focus());
return;
}
setError('');
p.onSave?.({ request: k, correction: k === 'correct' ? correction : undefined });
setSent((s) => ({ ...s, [k]: true }));
setOpen(null);
};
return (
<div className="mx-auto grid max-w-[46rem] gap-4">
<Reveal effect="rise" className="flex flex-wrap items-center justify-between gap-3">
<Address email={p.email} t={t} />
{p.answerTime && (
<p className="flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="clock" size={15} />
{t.answer.replace('{t}', p.answerTime)}
</p>
)}
</Reveal>
<Stagger step={80} as="ul" className="grid gap-3">
{items.map((it) => {
const isOpen = open === it.k;
const done = sent[it.k];
const danger = it.k === 'delete';
return (
<li key={it.k} className={cn('kit-card overflow-hidden transition-[border-color,box-shadow] duration-300', isOpen && (danger ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]'))}>
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
<span className={cn('grid size-11 shrink-0 place-items-center rounded-xl max-sm:hidden', danger ? 'bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive' : 'bg-surface text-muted-foreground')}>
<Icon name={done ? 'check' : it.icon} size={20} />
</span>
<div className="min-w-0 flex-1">
<h3 className="text-[15.5px] font-semibold text-foreground">{it.title}</h3>
<p className={cn('mt-0.5 text-pretty text-[13.5px] leading-snug', done ? 'text-success-foreground' : 'text-muted-foreground')} aria-live="polite">
{done ? it.done : it.text}
</p>
</div>
{done ? (
<span className="inline-flex h-9 shrink-0 items-center gap-1.5 self-start rounded-full bg-[color-mix(in_oklab,var(--success)_12%,var(--card))] px-3 text-[13px] font-medium text-success-foreground sm:self-center">
<Icon name="check-circle" size={15} />
{t.sent}
</span>
) : it.k === 'copy' || it.k === 'object' ? (
<Button size="sm" variant="outline" onClick={() => send(it.k)} className="max-sm:w-full">
{t.request}
</Button>
) : (
<Button size="sm" variant="outline" aria-expanded={isOpen} aria-controls={`${id}-${it.k}-panel`} icon={isOpen ? 'chevron-up' : 'chevron-down'} onClick={() => (setOpen(isOpen ? null : it.k), setError(''))} className={cn('max-sm:w-full', danger && 'text-destructive')}>
{danger ? t.openDelete : t.openCorrect}
</Button>
)}
</div>
{(it.k === 'correct' || it.k === 'delete') && !done && (
<Unfold open={isOpen}>
<form
id={`${id}-${it.k}-panel`}
noValidate
onSubmit={(e) => {
e.preventDefault();
send(it.k);
}}
className="grid gap-4 border-t border-border bg-surface p-5 sm:p-6"
>
{it.k === 'correct' ? (
<Textarea id={`${id}-correct`} label={t.correctLabel} value={correction} onChange={(e) => (setCorrection(e.target.value), setError(''))} error={error || undefined} minRows={3} maxLength={800} />
) : (
<Input id={`${id}-word`} label={t.delConfirm.replace('{w}', t.delWord)} autoComplete="off" value={word} onChange={(e) => (setWord(e.target.value), setError(''))} error={error || undefined} className="sm:max-w-[20rem]" />
)}
<Button type="submit" variant={danger ? 'destructive' : 'primary'} icon={danger ? 'trash' : 'send'} className="sm:justify-self-start max-sm:w-full">
{danger ? t.del : t.request}
</Button>
</form>
</Unfold>
)}
</li>
);
})}
</Stagger>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* The page where people decide what they receive and what happens with their data.
* <ConsentPreferences variant="email-topics" email="lena@example.com" topics={[{ id, title, text, on, locked }]} frequencies={['Weekly', 'Monthly']} />
*/
export function ConsentPreferences(props: ConsentPreferencesProps) {
const { variant = 'email-topics', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const locale = useLocale();
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
{variant === 'email-topics' && <EmailTopics p={props} t={t} />}
{variant === 'unsubscribe' && <Unsubscribe p={props} t={t} reasons={REASONS[locale]} />}
{variant === 'channel-matrix' && <ChannelMatrix p={props} t={t} />}
{variant === 'privacy-requests' && <PrivacyRequests p={props} t={t} />}
</Section>
);
}Häufige Fragen
Wie installiere ich Einstellungen und Einwilligungen?
Mit aiw add @ai-website/consent-preferences. Der Befehl legt die Datei components/ui/consent-preferences.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow, input, textarea und switch. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Einstellungen und Einwilligungen?
4 Varianten, E-Mail-Themen, Abmelden, Kanäle und Datenschutz-Anfragen. Du wählst sie mit der Prop variant, zum Beispiel variant="unsubscribe".
Kann ich die Texte von Einstellungen und Einwilligungen übersetzen?
Ja. Einstellungen und Einwilligungen bringt 49 eigene Texte auf Deutsch und Englisch mit, etwa „Wie oft?“, „Alle E-Mails 30 Tage pausieren“ und „Wir behalten deine Auswahl und starten nach der Pause wieder.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Einstellungen und Einwilligungen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Einstellungen und Einwilligungen mit aiw_components und installieren sie mit aiw_add @ai-website/consent-preferences. Nenn im Prompt die Variante, etwa variant="email-topics", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Einstellungen und Einwilligungen ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ConsentPreferences-Code zum Kopieren.