Anmeldung
Anmelden zu Veranstaltung, Kurs oder Turnier, für eine Person oder viele. Ein Block je Ticket, Termine mit freien Plätzen und Warteliste, ein Team mit Kapitänin, Ticketarten mit Nachweis und Workshops, die vor Überschneidungen warnen.
Eine von 3 Components in Bewerbungen und Anmeldungen
Variants
Installv1.0.0
aiw add @ai-website/registrationBringt mit utils, kit, button, icon, reveal, section, motion, form-flow, input, select, number-input, checkbox, file-upload
Usage
<Registration variant="tickets" tickets={[{ value, name, price, proof }]} workshops={[{ value, title, slot, host }]} currency="€" />Details
Was Anmeldung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Bewerbungen und Anmeldungen
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/registration.tsx
- Bringt mit
- utilskitbuttoniconrevealsectionmotionform-flowinputselectnumber-inputcheckboxfile-upload
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 46 eigene Texte
- In Sammlungen
- Event
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle RegistrationProps aus components/ui/registration.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalRegistrationVariantattendeessessionsteamtickets
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeeventoptional{ name: string; date?: string; place?: string; icon?: IconName }The event in a short card: name, date, place
priceoptionalnumberattendees: price per ticket, most tickets, diet choices
maxTicketsoptionalnumberdietsoptionalstring[]sessionsoptionalRegistrationSession[]sessions
minMembersoptionalnumberteam: members needed and allowed, categories, shirt sizes
maxMembersoptionalnumbercategoriesoptionalstring[]sizesoptionalstring[]ticketsoptionalRegistrationTicket[]tickets
workshopsoptionalRegistrationWorkshop[]currencyoptionalstringstepsoptionalDoneStep[]submitLabeloptionalstringdoneoptional{ title: string; text?: string }onSubmitoptional(data: Record<string, unknown>) => void
Eingebaute Texte
Diese Texte schreibt Anmeldung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 46 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| tickets | Englisch Tickets | Deutsch Tickets |
| name | Englisch Name | Deutsch Name |
| firstName | Englisch First name | Deutsch Vorname |
| lastName | Englisch Last name | Deutsch Nachname |
| nameNeeded | Englisch Please add a name, it goes on the ticket. | Deutsch Bitte gib einen Namen an, er steht auf dem Ticket. |
| Englisch Email | Deutsch E-Mail | |
| emailNeeded | Englisch This address looks incomplete. Please use a form like name@example.com so the ticket reaches you. | Deutsch Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit das Ticket dich erreicht. |
| attendee | Englisch Participant {n} | Deutsch Person {n} |
| same | Englisch This is me | Deutsch Das bin ich |
| diet | Englisch Food | Deutsch Essen |
| total | Englisch Total | Deutsch Gesamt |
| fromPrice | Englisch from {p} | Deutsch ab {p} |
| session | Englisch Choose a date | Deutsch Wähle einen Termin |
| sessionNeeded | Englisch Please choose a date, so we can hold a seat for you. | Deutsch Bitte wähle einen Termin, damit wir dir einen Platz freihalten. |
| left | Englisch {n} of {total} seats left | Deutsch Noch {n} von {total} Plätzen |
| full | Englisch Full, waitlist open | Deutsch Voll, Warteliste offen |
| waitlistNote | Englisch You are on the waitlist for this date. If a seat frees up, we write to you at once. | Deutsch Du stehst auf der Warteliste für diesen Termin. Wird ein Platz frei, schreiben wir dir sofort. |
| teamName | Englisch Team name | Deutsch Teamname |
| teamNeeded | Englisch Please name your team, it goes on the start list. | Deutsch Bitte gib eurem Team einen Namen, er steht auf der Startliste. |
| category | Englisch Category | Deutsch Kategorie |
| categoryNeeded | Englisch Please choose a category, it decides your start time. | Deutsch Bitte wähle eine Kategorie, sie bestimmt eure Startzeit. |
| captain | Englisch Captain | Deutsch Kapitänin oder Kapitän |
| members | Englisch Team members | Deutsch Teammitglieder |
| member | Englisch Member {n} | Deutsch Mitglied {n} |
| addMember | Englisch Add a member | Deutsch Mitglied hinzufügen |
| removeMember | Englisch Remove member {n} | Deutsch Mitglied {n} entfernen |
| membersCount | Englisch {n} of {min} needed | Deutsch {n} von {min} nötig |
| membersReady | Englisch {n} members, ready to start | Deutsch {n} Mitglieder, startklar |
| membersNeeded | Englisch Please add at least {n} members, the rules ask for it. | Deutsch Bitte trage mindestens {n} Mitglieder ein, das verlangen die Regeln. |
| size | Englisch Shirt size | Deutsch Shirtgröße |
| ticket | Englisch Ticket | Deutsch Ticket |
| ticketNeeded | Englisch Please choose a ticket. | Deutsch Bitte wähle ein Ticket. |
| proof | Englisch Proof | Deutsch Nachweis |
| proofNeeded | Englisch Please add your proof for the reduced ticket, a photo is fine. | Deutsch Bitte füge den Nachweis für das ermäßigte Ticket hinzu, ein Foto reicht. |
| workshops | Englisch Workshops | Deutsch Workshops |
| workshopsHint | Englisch One per slot, workshops in the same slot run at the same time | Deutsch Einer je Zeitfenster, Workshops im selben Fenster laufen gleichzeitig |
| clash | Englisch {a} and {b} run at the same time. Please keep one of them. | Deutsch {a} und {b} laufen gleichzeitig. Bitte behalte einen davon. |
| slot | Englisch Slot {s} | Deutsch Um {s} Uhr |
| consent | Englisch I agree that my details are used to run this event. | Deutsch Ich bin einverstanden, dass meine Angaben für diese Veranstaltung genutzt werden. |
| consentNeeded | Englisch Please agree so we are allowed to register you. | Deutsch Bitte stimme zu, damit wir dich anmelden dürfen. |
| send | Englisch Register | Deutsch Anmelden |
| sendWait | Englisch Join the waitlist | Deutsch Auf die Warteliste |
| doneTitle | Englisch You are registered | Deutsch Du bist angemeldet |
| doneText | Englisch Your confirmation and tickets are in your inbox. | Deutsch Bestätigung und Tickets liegen in deinem Postfach. |
| waitTitle | Englisch You are on the waitlist | Deutsch Du stehst auf der Warteliste |
| again | Englisch Register someone else | Deutsch Weitere Person anmelden |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle alert.
- 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.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 761 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, 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 { NativeSelect } from '@/components/ui/select';
import { NumberInput } from '@/components/ui/number-input';
import { Checkbox } from '@/components/ui/checkbox';
import { FileUpload } from '@/components/ui/file-upload';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ChipGroup, DonePanel, ErrorSummary, FlowHead, isEmail, Unfold, useFocusFirstError, type DoneStep, summaryOf, useFormState } from '@/components/ui/form-flow';
/*
* Signing up for an event, a course or a tournament, for one person or many.
*
* attendees one block per ticket, the first can take the buyer's details
* sessions dates with seats left on a bar, full ones turn into a waitlist
* team a team with a captain and members, sizes as chips, a counter to the minimum
* tickets ticket types with proof for reduced ones, workshops that warn about a clash
*
* Every variant checks before sending, lists what is missing above the form
* and focuses the first open field. Sends everything to `onSubmit`.
*/
export type RegistrationVariant = 'attendees' | 'sessions' | 'team' | 'tickets';
export interface RegistrationSession {
value: string;
/** e.g. "Tue 4 Nov, 18:00 to 20:30" */
label: string;
detail?: string;
seats: number;
taken: number;
}
export interface RegistrationTicket {
value: string;
name: string;
price: number;
text?: string;
/** Asks for a proof, e.g. a student card */
proof?: string;
}
export interface RegistrationWorkshop {
value: string;
title: string;
/** Workshops with the same slot clash */
slot: string;
host?: string;
}
export interface RegistrationProps {
variant?: RegistrationVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** The event in a short card: name, date, place */
event?: { name: string; date?: string; place?: string; icon?: IconName };
/** attendees: price per ticket, most tickets, diet choices */
price?: number;
maxTickets?: number;
diets?: string[];
/** sessions */
sessions?: RegistrationSession[];
/** team: members needed and allowed, categories, shirt sizes */
minMembers?: number;
maxMembers?: number;
categories?: string[];
sizes?: string[];
/** tickets */
tickets?: RegistrationTicket[];
workshops?: RegistrationWorkshop[];
currency?: string;
steps?: DoneStep[];
submitLabel?: string;
done?: { title: string; text?: string };
onSubmit?: (data: Record<string, unknown>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
tickets: 'Tickets',
name: 'Name',
firstName: 'First name',
lastName: 'Last name',
nameNeeded: 'Please add a name, it goes on the ticket.',
email: 'Email',
emailNeeded: 'This address looks incomplete. Please use a form like name@example.com so the ticket reaches you.',
attendee: 'Participant {n}',
same: 'This is me',
diet: 'Food',
total: 'Total',
fromPrice: 'from {p}',
session: 'Choose a date',
sessionNeeded: 'Please choose a date, so we can hold a seat for you.',
left: '{n} of {total} seats left',
full: 'Full, waitlist open',
waitlistNote: 'You are on the waitlist for this date. If a seat frees up, we write to you at once.',
teamName: 'Team name',
teamNeeded: 'Please name your team, it goes on the start list.',
category: 'Category',
categoryNeeded: 'Please choose a category, it decides your start time.',
captain: 'Captain',
members: 'Team members',
member: 'Member {n}',
addMember: 'Add a member',
removeMember: 'Remove member {n}',
membersCount: '{n} of {min} needed',
membersReady: '{n} members, ready to start',
membersNeeded: 'Please add at least {n} members, the rules ask for it.',
size: 'Shirt size',
ticket: 'Ticket',
ticketNeeded: 'Please choose a ticket.',
proof: 'Proof',
proofNeeded: 'Please add your proof for the reduced ticket, a photo is fine.',
workshops: 'Workshops',
workshopsHint: 'One per slot, workshops in the same slot run at the same time',
clash: '{a} and {b} run at the same time. Please keep one of them.',
slot: 'Slot {s}',
consent: 'I agree that my details are used to run this event.',
consentNeeded: 'Please agree so we are allowed to register you.',
send: 'Register',
sendWait: 'Join the waitlist',
doneTitle: 'You are registered',
doneText: 'Your confirmation and tickets are in your inbox.',
waitTitle: 'You are on the waitlist',
again: 'Register someone else',
},
de: {
tickets: 'Tickets',
name: 'Name',
firstName: 'Vorname',
lastName: 'Nachname',
nameNeeded: 'Bitte gib einen Namen an, er steht auf dem Ticket.',
email: 'E-Mail',
emailNeeded: 'Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit das Ticket dich erreicht.',
attendee: 'Person {n}',
same: 'Das bin ich',
diet: 'Essen',
total: 'Gesamt',
fromPrice: 'ab {p}',
session: 'Wähle einen Termin',
sessionNeeded: 'Bitte wähle einen Termin, damit wir dir einen Platz freihalten.',
left: 'Noch {n} von {total} Plätzen',
full: 'Voll, Warteliste offen',
waitlistNote: 'Du stehst auf der Warteliste für diesen Termin. Wird ein Platz frei, schreiben wir dir sofort.',
teamName: 'Teamname',
teamNeeded: 'Bitte gib eurem Team einen Namen, er steht auf der Startliste.',
category: 'Kategorie',
categoryNeeded: 'Bitte wähle eine Kategorie, sie bestimmt eure Startzeit.',
captain: 'Kapitänin oder Kapitän',
members: 'Teammitglieder',
member: 'Mitglied {n}',
addMember: 'Mitglied hinzufügen',
removeMember: 'Mitglied {n} entfernen',
membersCount: '{n} von {min} nötig',
membersReady: '{n} Mitglieder, startklar',
membersNeeded: 'Bitte trage mindestens {n} Mitglieder ein, das verlangen die Regeln.',
size: 'Shirtgröße',
ticket: 'Ticket',
ticketNeeded: 'Bitte wähle ein Ticket.',
proof: 'Nachweis',
proofNeeded: 'Bitte füge den Nachweis für das ermäßigte Ticket hinzu, ein Foto reicht.',
workshops: 'Workshops',
workshopsHint: 'Einer je Zeitfenster, Workshops im selben Fenster laufen gleichzeitig',
clash: '{a} und {b} laufen gleichzeitig. Bitte behalte einen davon.',
slot: 'Um {s} Uhr',
consent: 'Ich bin einverstanden, dass meine Angaben für diese Veranstaltung genutzt werden.',
consentNeeded: 'Bitte stimme zu, damit wir dich anmelden dürfen.',
send: 'Anmelden',
sendWait: 'Auf die Warteliste',
doneTitle: 'Du bist angemeldet',
doneText: 'Bestätigung und Tickets liegen in deinem Postfach.',
waitTitle: 'Du stehst auf der Warteliste',
again: 'Weitere Person anmelden',
},
};
type Texts = (typeof TEXTS)['en'];
const money = (n: number, c: string, locale: 'en' | 'de') => (locale === 'de' ? `${n.toLocaleString('de-DE')} ${c}` : `${c}${n.toLocaleString('en-US')}`);
function EventCard({ event }: { event: NonNullable<RegistrationProps['event']> }) {
return (
<div className="flex items-center gap-4 border-b border-border bg-surface px-6 py-4 sm:px-8">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<Icon name={event.icon ?? 'ticket'} size={20} />
</span>
<span className="min-w-0">
<span className="block text-[16px] font-semibold leading-snug text-foreground">{event.name}</span>
<span className="block text-[13px] text-muted-foreground">{[event.date, event.place].filter(Boolean).join(' · ')}</span>
</span>
</div>
);
}
function Done({ p, t, onAgain, title, text, className }: { p: RegistrationProps; t: Texts; onAgain: () => void; title?: string; text?: string; className?: string }) {
return (
<div className={cn('kit-card mx-auto p-6 sm:p-10', className)}>
<DonePanel
title={title ?? p.done?.title ?? t.doneTitle}
text={text ?? p.done?.text ?? t.doneText}
steps={p.steps}
actions={
<Button variant="outline" iconLeft="user-plus" onClick={onAgain}>
{t.again}
</Button>
}
/>
</div>
);
}
function Consent({ id, t, f, value, set }: { id: string; t: Texts; f: ReturnType<typeof useFormState>; value: boolean; set: (v: boolean) => void }) {
return <Checkbox id={`${id}-consent`} label={t.consent} checked={value} onChange={(e) => (set(e.target.checked), f.clear('consent'))} error={f.errors.consent} />;
}
/* ------------------------------------------------------------------ */
/* attendees */
/* ------------------------------------------------------------------ */
interface Person {
first: string;
last: string;
email: string;
diet: string;
}
const blank = (diet = ''): Person => ({ first: '', last: '', email: '', diet });
function Attendees({ p, t }: { p: RegistrationProps; t: Texts }) {
const id = useId();
const locale = useLocale();
const f = useFormState();
const diets = p.diets ?? [];
const [count, setCount] = useState(2);
const [people, setPeople] = useState<Person[]>([blank(diets[0]), blank(diets[0])]);
const [buyer, setBuyer] = useState({ name: '', email: '' });
const [sameAsBuyer, setSame] = useState(false);
const [consent, setConsent] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const c = p.currency ?? '€';
const setCountAndPeople = (n: number) => {
setCount(n);
setPeople((x) => (n > x.length ? [...x, ...Array.from({ length: n - x.length }, () => blank(diets[0]))] : x.slice(0, n)));
};
const setPerson = (i: number, patch: Partial<Person>) => setPeople((x) => x.map((y, j) => (j === i ? { ...y, ...patch } : y)));
const shown = people.map((x, i) => (i === 0 && sameAsBuyer ? { ...x, first: buyer.name.split(' ')[0] ?? '', last: buyer.name.split(' ').slice(1).join(' '), email: buyer.email } : x));
const summary = summaryOf(f.errors, [
['buyer', `${id}-buyer`, t.name],
['buyerEmail', `${id}-buyer-email`, t.email],
...people.map((_, i) => [`p${i}`, `${id}-p${i}-first`, t.attendee.replace('{n}', String(i + 1))] as [string, string, string]),
['consent', `${id}-consent`, t.consent],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setPeople([blank(diets[0]), blank(diets[0])]), setCount(2), setConsent(false), f.setDone(false))} className="max-w-[46rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[52rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!buyer.name.trim()) found.buyer = t.nameNeeded;
if (!isEmail(buyer.email)) found.buyerEmail = t.emailNeeded;
shown.forEach((x, i) => {
if (!x.first.trim()) found[`p${i}`] = t.nameNeeded;
});
if (!consent) found.consent = t.consentNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ buyer, attendees: shown, total: count * (p.price ?? 0) });
f.setDone(true);
}}
>
{p.event && <EventCard event={p.event} />}
<div className="grid gap-7 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<div className="grid grid-cols-1 gap-5 sm:grid-cols-[minmax(0,1fr)_14rem] sm:items-end">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-buyer`} label={t.name} required autoComplete="name" value={buyer.name} onChange={(e) => (setBuyer((b) => ({ ...b, name: e.target.value })), f.clear('buyer'))} error={f.errors.buyer} />
<Input id={`${id}-buyer-email`} type="email" label={t.email} required autoComplete="email" value={buyer.email} onChange={(e) => (setBuyer((b) => ({ ...b, email: e.target.value })), f.clear('buyerEmail'))} error={f.errors.buyerEmail} />
</div>
<NumberInput label={t.tickets} variant="stepper" min={1} max={p.maxTickets ?? 8} value={count} onValueChange={setCountAndPeople} />
</div>
{/* One block per ticket, new ones rise in, removed ones go */}
<Stagger step={80} as="ol" className="grid gap-3">
{people.map((x, i) => {
const linked = i === 0 && sameAsBuyer;
const v = shown[i];
return (
<li key={i} className="kit-rise rounded-xl border border-border bg-surface p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-2.5 text-[14px] font-semibold text-foreground">
<span className="grid size-7 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[12px] tabular-nums text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{i + 1}</span>
{t.attendee.replace('{n}', String(i + 1))}
</p>
{i === 0 && <Checkbox label={t.same} checked={sameAsBuyer} onChange={(e) => (setSame(e.target.checked), f.clear('p0'))} />}
</div>
<div className="mt-4 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-[1fr_1fr_1.2fr_10rem]">
<Input id={`${id}-p${i}-first`} label={t.firstName} required autoComplete="off" disabled={linked} value={v.first} onChange={(e) => (setPerson(i, { first: e.target.value }), f.clear(`p${i}`))} error={f.errors[`p${i}`]} />
<Input label={t.lastName} optional autoComplete="off" disabled={linked} value={v.last} onChange={(e) => setPerson(i, { last: e.target.value })} />
<Input type="email" label={t.email} optional autoComplete="off" disabled={linked} value={v.email} onChange={(e) => setPerson(i, { email: e.target.value })} className="max-sm:col-span-2" />
{diets.length > 0 && <NativeSelect label={t.diet} options={diets.map((d) => ({ value: d, label: d }))} value={x.diet} onChange={(e) => setPerson(i, { diet: e.target.value })} className="max-sm:col-span-2" />}
</div>
</li>
);
})}
</Stagger>
<Consent id={id} t={t} f={f} value={consent} set={setConsent} />
</div>
<div className="flex flex-col gap-4 border-t border-border bg-surface px-6 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p className="flex items-baseline gap-2">
<span className="text-[14px] text-muted-foreground">{t.total}</span>
<span key={count} className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)]">
{money(count * (p.price ?? 0), c, locale)}
</span>
<span className="text-[13px] tabular-nums text-muted-foreground">
{count} × {money(p.price ?? 0, c, locale)}
</span>
</p>
<Button type="submit" icon="arrow-right" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* sessions */
/* ------------------------------------------------------------------ */
function Sessions({ p, t }: { p: RegistrationProps; t: Texts }) {
const id = useId();
const f = useFormState();
const sessions = p.sessions ?? [];
const [pick, setPick] = useState('');
const [v, setV] = useState({ name: '', email: '' });
const [consent, setConsent] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const chosen = sessions.find((s) => s.value === pick);
const wait = chosen ? chosen.taken >= chosen.seats : false;
const summary = summaryOf(f.errors, [
['session', `${id}-s0`, t.session],
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
['consent', `${id}-consent`, t.consent],
]);
if (f.done) return <Done p={p} t={t} title={wait ? t.waitTitle : undefined} text={wait ? t.waitlistNote : undefined} onAgain={() => (setPick(''), setConsent(false), f.setDone(false))} className="max-w-[46rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[48rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!pick) found.session = t.sessionNeeded;
if (!v.name.trim()) found.name = t.nameNeeded;
if (!isEmail(v.email)) found.email = t.emailNeeded;
if (!consent) found.consent = t.consentNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ session: pick, waitlist: wait, ...v });
f.setDone(true);
}}
>
{p.event && <EventCard event={p.event} />}
<div className="grid gap-7 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<fieldset className="min-w-0" aria-describedby={f.errors.session ? `${id}-se` : undefined}>
<legend className="text-[15px] font-semibold text-foreground">{t.session}</legend>
<Stagger step={80} as="div" className="mt-4 grid gap-2.5">
{sessions.map((s, i) => {
const left = Math.max(0, s.seats - s.taken);
const full = left === 0;
const share = Math.min(1, s.taken / Math.max(1, s.seats));
const on = pick === s.value;
return (
<label key={s.value} htmlFor={`${id}-s${i}`} className={cn('group/s relative grid cursor-pointer grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 rounded-xl border bg-card p-4 transition-[border-color,box-shadow,background-color] duration-300 sm:grid-cols-[auto_minmax(0,1fr)_12rem]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,var(--border))]' : f.errors.session ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border hover:border-border-strong', 'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]')}>
<input id={`${id}-s${i}`} type="radio" name={`${id}-session`} value={s.value} checked={on} onChange={() => (setPick(s.value), f.clear('session'))} aria-invalid={f.errors.session ? true : undefined} aria-describedby={`${id}-s${i}-seats`} className="sr-only" />
<span aria-hidden className={cn('grid size-5 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
<span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-100' : 'scale-0')} />
</span>
<span className="min-w-0">
<span className="block text-[15px] font-medium text-foreground">{s.label}</span>
{s.detail && <span className="block text-[13px] text-muted-foreground">{s.detail}</span>}
</span>
{/* Seats as a bar that fills to what is taken */}
<span className="col-span-2 sm:col-span-1">
<span id={`${id}-s${i}-seats`} className={cn('flex items-center gap-1.5 text-[12.5px] tabular-nums', full ? 'font-medium text-warning-foreground' : left <= 3 ? 'font-medium text-foreground' : 'text-muted-foreground')}>
{full && <Icon name="hourglass" size={13} />}
{full ? t.full : t.left.replace('{n}', String(left)).replace('{total}', String(s.seats))}
</span>
<span aria-hidden className="mt-1.5 block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_9%,transparent)]">
<span className={cn('block h-full origin-left rounded-full transition-transform duration-1000 ease-[var(--ease-kit)]', full ? 'bg-warning' : 'bg-primary')} style={{ transform: `scaleX(${round(share, 3)})` }} />
</span>
</span>
</label>
);
})}
</Stagger>
{f.errors.session && (
<p id={`${id}-se`} className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{f.errors.session}
</p>
)}
{/* Closed, the note takes no room, so the next block keeps its usual distance */}
<Unfold open={wait}>
<p className="mt-5 flex items-start gap-3 rounded-xl bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] p-4 text-[14px] leading-snug text-warning-foreground">
<Icon name="info" size={18} className="mt-px shrink-0" />
{t.waitlistNote}
</p>
</Unfold>
</fieldset>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={(e) => (setV((x) => ({ ...x, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={(e) => (setV((x) => ({ ...x, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
</div>
<Consent id={id} t={t} f={f} value={consent} set={setConsent} />
<div className="flex justify-end border-t border-border pt-6">
<Button type="submit" variant={wait ? 'outline' : 'primary'} icon={wait ? 'hourglass' : 'arrow-right'} className="max-sm:w-full">
{wait ? t.sendWait : (p.submitLabel ?? t.send)}
</Button>
</div>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* team */
/* ------------------------------------------------------------------ */
interface Member {
name: string;
size: string;
}
function Team({ p, t }: { p: RegistrationProps; t: Texts }) {
const id = useId();
const f = useFormState();
const min = p.minMembers ?? 4;
const max = p.maxMembers ?? 8;
const sizes = p.sizes ?? ['S', 'M', 'L', 'XL'];
const [team, setTeam] = useState('');
const [category, setCategory] = useState<string[]>([]);
const [captain, setCaptain] = useState({ name: '', email: '', size: sizes[1] ?? sizes[0] });
const [members, setMembers] = useState<Member[]>(Array.from({ length: min - 1 }, () => ({ name: '', size: sizes[1] ?? sizes[0] })));
const [consent, setConsent] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
const added = useRef(false);
useFocusFirstError(formRef, f.attempt, summaryRef);
useEffect(() => {
if (!added.current) return;
added.current = false;
document.getElementById(`${id}-m${members.length - 1}`)?.focus();
}, [members.length, id]);
const filled = 1 + members.filter((m) => m.name.trim()).length;
const ready = filled >= min;
const summary = summaryOf(f.errors, [
['team', `${id}-team`, t.teamName],
['category', `${id}-cat`, t.category],
['captain', `${id}-captain`, t.captain],
['email', `${id}-email`, t.email],
['members', `${id}-m0`, t.members],
['consent', `${id}-consent`, t.consent],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setTeam(''), setConsent(false), f.setDone(false))} className="max-w-[46rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[52rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!team.trim()) found.team = t.teamNeeded;
if (!category.length) found.category = t.categoryNeeded;
if (!captain.name.trim()) found.captain = t.nameNeeded;
if (!isEmail(captain.email)) found.email = t.emailNeeded;
if (!ready) found.members = t.membersNeeded.replace('{n}', String(min));
if (!consent) found.consent = t.consentNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ team, category: category[0], captain, members: members.filter((m) => m.name.trim()) });
f.setDone(true);
}}
>
{p.event && <EventCard event={p.event} />}
<div className="grid gap-7 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-team`} label={t.teamName} required value={team} onChange={(e) => (setTeam(e.target.value), f.clear('team'))} error={f.errors.team} size="lg" />
<ChipGroup id={`${id}-cat`} legend={t.category} required options={(p.categories ?? []).map((x) => ({ value: x, label: x }))} value={category} onChange={(v) => (setCategory(v), f.clear('category'))} error={f.errors.category} />
</div>
<fieldset className="min-w-0 rounded-xl border border-border bg-surface p-4 sm:p-5">
<legend className="sr-only">{t.captain}</legend>
<p aria-hidden className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
<Icon name="crown" size={16} className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
{t.captain}
</p>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_8rem]">
<Input id={`${id}-captain`} label={t.name} required autoComplete="name" value={captain.name} onChange={(e) => (setCaptain((c) => ({ ...c, name: e.target.value })), f.clear('captain'))} error={f.errors.captain} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={captain.email} onChange={(e) => (setCaptain((c) => ({ ...c, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
<NativeSelect label={t.size} options={sizes.map((s) => ({ value: s, label: s }))} value={captain.size} onChange={(e) => setCaptain((c) => ({ ...c, size: e.target.value }))} />
</div>
</fieldset>
<fieldset className="min-w-0">
<div className="flex flex-wrap items-center justify-between gap-3">
<legend className="text-[15px] font-semibold text-foreground">{t.members}</legend>
{/* Dots to the minimum, they fill as names come in */}
<p aria-live="polite" className={cn('flex items-center gap-2 text-[13px] tabular-nums', ready ? 'font-medium text-success-foreground' : 'text-muted-foreground')}>
<span aria-hidden className="flex gap-1">
{Array.from({ length: min }, (_, i) => (
<span key={i} className={cn('size-2 rounded-full transition-[background-color,scale] duration-300', i < filled ? 'scale-100 bg-primary' : 'scale-90 bg-[color-mix(in_oklab,var(--foreground)_15%,transparent)]')} />
))}
</span>
{ready ? t.membersReady.replace('{n}', String(filled)) : t.membersCount.replace('{n}', String(filled)).replace('{min}', String(min))}
</p>
</div>
<ol className="mt-4 grid gap-2.5">
{members.map((m, i) => (
<li key={i} className="kit-rise grid grid-cols-[minmax(0,1fr)_5.5rem_2.75rem] items-center gap-2 sm:grid-cols-[2rem_minmax(0,1fr)_8rem_2.75rem] sm:gap-3">
<span aria-hidden className="hidden text-[13px] font-semibold tabular-nums text-subtle-foreground sm:block">
{i + 2}
</span>
<Input id={`${id}-m${i}`} aria-label={t.member.replace('{n}', String(i + 2))} placeholder={t.member.replace('{n}', String(i + 2))} value={m.name} invalid={Boolean(f.errors.members) && !m.name.trim()} aria-describedby={f.errors.members ? `${id}-me` : undefined} onChange={(e) => (setMembers((x) => x.map((y, j) => (j === i ? { ...y, name: e.target.value } : y))), f.clear('members'))} />
<NativeSelect aria-label={`${t.member.replace('{n}', String(i + 2))}, ${t.size}`} options={sizes.map((s) => ({ value: s, label: s }))} value={m.size} onChange={(e) => setMembers((x) => x.map((y, j) => (j === i ? { ...y, size: e.target.value } : y)))} />
<button type="button" aria-label={t.removeMember.replace('{n}', String(i + 2))} onClick={() => setMembers((x) => x.filter((_, j) => j !== i))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none">
<Icon name="trash" size={17} />
</button>
</li>
))}
</ol>
{f.errors.members && (
<p id={`${id}-me`} className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{f.errors.members}
</p>
)}
{members.length + 1 < max && (
<Button
type="button"
variant="outline"
size="sm"
iconLeft="user-plus"
className="mt-3"
onClick={() => {
added.current = true;
setMembers((x) => [...x, { name: '', size: sizes[1] ?? sizes[0] }]);
}}
>
{t.addMember}
</Button>
)}
</fieldset>
<Consent id={id} t={t} f={f} value={consent} set={setConsent} />
<div className="flex justify-end border-t border-border pt-6">
<Button type="submit" icon="arrow-right" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* tickets */
/* ------------------------------------------------------------------ */
function Tickets({ p, t }: { p: RegistrationProps; t: Texts }) {
const id = useId();
const locale = useLocale();
const f = useFormState();
const c = p.currency ?? '€';
const tickets = p.tickets ?? [];
const workshops = p.workshops ?? [];
const [ticket, setTicket] = useState('');
const [proof, setProof] = useState<File[]>([]);
const [picked, setPicked] = useState<string[]>([]);
const [v, setV] = useState({ name: '', email: '' });
const [consent, setConsent] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const chosen = tickets.find((x) => x.value === ticket);
const slots = [...new Set(workshops.map((w) => w.slot))];
// Two workshops in the same slot clash
const clashes = slots
.map((s) => workshops.filter((w) => w.slot === s && picked.includes(w.value)))
.filter((list) => list.length > 1);
const summary = summaryOf(f.errors, [
['ticket', `${id}-t0`, t.ticket],
['proof', `${id}-proof`, t.proof],
['clash', `${id}-ws`, t.workshops],
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
['consent', `${id}-consent`, t.consent],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setTicket(''), setPicked([]), setConsent(false), f.setDone(false))} className="max-w-[46rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[56rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!ticket) found.ticket = t.ticketNeeded;
if (chosen?.proof && !proof.length) found.proof = t.proofNeeded;
if (clashes.length) found.clash = t.clash.replace('{a}', clashes[0][0].title).replace('{b}', clashes[0][1].title);
if (!v.name.trim()) found.name = t.nameNeeded;
if (!isEmail(v.email)) found.email = t.emailNeeded;
if (!consent) found.consent = t.consentNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ ticket, workshops: picked, ...v, proof: proof[0] });
f.setDone(true);
}}
>
{p.event && <EventCard event={p.event} />}
<div className="grid gap-8 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<fieldset className="min-w-0" aria-describedby={f.errors.ticket ? `${id}-te` : undefined}>
<legend className="text-[15px] font-semibold text-foreground">{t.ticket}</legend>
<Stagger step={80} as="div" className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
{tickets.map((x, i) => {
const on = ticket === x.value;
return (
<label key={x.value} htmlFor={`${id}-t${i}`} className={cn('flex h-full cursor-pointer flex-col rounded-xl border bg-card p-4 transition-[border-color,box-shadow] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,var(--border))]' : f.errors.ticket ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border hover:border-border-strong', 'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]')}>
<input id={`${id}-t${i}`} type="radio" name={`${id}-ticket`} value={x.value} checked={on} onChange={() => (setTicket(x.value), f.clear('ticket'), f.clear('proof'))} className="sr-only" />
<span className="flex items-center justify-between gap-3">
<span className="text-[15px] font-semibold text-foreground">{x.name}</span>
<span aria-hidden className={cn('grid size-5 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
<span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300', on ? 'scale-100' : 'scale-0')} />
</span>
</span>
<span className="mt-1.5 text-[20px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{money(x.price, c, locale)}</span>
{x.text && <span className="mt-1 text-[13px] leading-snug text-muted-foreground">{x.text}</span>}
</label>
);
})}
</Stagger>
{f.errors.ticket && (
<p id={`${id}-te`} className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{f.errors.ticket}
</p>
)}
{/* A reduced ticket asks for its proof, closed it takes no room */}
<Unfold open={Boolean(chosen?.proof)}>
<div id={`${id}-proof`} className="pt-6">
<FileUpload variant="compact" label={<>{t.proof}<span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">*</span></>} hint={chosen?.proof} accept=".pdf,image/*" maxSize={10e6} maxFiles={1} multiple={false} items={proof.map((x, i) => ({ id: `${i}`, name: x.name, size: x.size, status: 'done' as const }))} onFiles={(x) => (setProof(x.slice(0, 1)), f.clear('proof'))} onRemove={() => setProof([])} error={f.errors.proof} />
</div>
</Unfold>
</fieldset>
{workshops.length > 0 && (
<fieldset id={`${id}-ws`} className="min-w-0" tabIndex={-1}>
<legend className="text-[15px] font-semibold text-foreground">{t.workshops}</legend>
<p className="mt-1 text-[13px] text-muted-foreground">{t.workshopsHint}</p>
<div className="mt-4 grid grid-cols-1 gap-5 md:grid-cols-2">
{slots.map((s) => {
const clash = clashes.some((list) => list[0].slot === s);
return (
<div key={s} className={cn('rounded-xl border p-4 transition-colors duration-300', clash ? 'border-[color-mix(in_oklab,var(--destructive)_50%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_4%,var(--card))]' : 'border-border bg-surface')}>
<p className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<Icon name="clock" size={14} />
{t.slot.replace('{s}', s)}
</p>
<div className="mt-3 grid gap-2.5">
{workshops
.filter((w) => w.slot === s)
.map((w) => (
<Checkbox
key={w.value}
label={w.title}
description={w.host}
checked={picked.includes(w.value)}
onChange={(e) => {
setPicked((x) => (e.target.checked ? [...x, w.value] : x.filter((y) => y !== w.value)));
f.clear('clash');
}}
/>
))}
</div>
{clash && (
<p role="alert" className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{(() => {
const list = clashes.find((l) => l[0].slot === s) ?? [];
return t.clash.replace('{a}', list[0]?.title ?? '').replace('{b}', list[1]?.title ?? '');
})()}
</p>
)}
</div>
);
})}
</div>
</fieldset>
)}
<div className="grid grid-cols-1 gap-5 border-t border-border pt-7 sm:grid-cols-2">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={(e) => (setV((x) => ({ ...x, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={(e) => (setV((x) => ({ ...x, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
</div>
<Consent id={id} t={t} f={f} value={consent} set={setConsent} />
</div>
<div className="flex flex-col gap-4 border-t border-border bg-surface px-6 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p className="flex items-baseline gap-2">
<span className="text-[14px] text-muted-foreground">{t.total}</span>
<span key={ticket} className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)]">
{chosen ? money(chosen.price, c, locale) : t.fromPrice.replace('{p}', money(Math.min(...tickets.map((x) => x.price)), c, locale))}
</span>
</p>
<Button type="submit" icon="arrow-right" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* Sign-up for events, courses and tournaments.
* <Registration variant="attendees" event={{ name, date, place }} price={45} diets={['Any', 'Vegetarian']} />
*/
export function Registration(props: RegistrationProps) {
const { variant = 'attendees', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
{variant === 'attendees' && <Attendees p={props} t={t} />}
{variant === 'sessions' && <Sessions p={props} t={t} />}
{variant === 'team' && <Team p={props} t={t} />}
{variant === 'tickets' && <Tickets p={props} t={t} />}
</Section>
);
}Häufige Fragen
Wie installiere ich Anmeldung?
Mit aiw add @ai-website/registration. Der Befehl legt die Datei components/ui/registration.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow, input, select, number-input, checkbox und file-upload. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Anmeldung?
4 Varianten, Mehrere Personen, Termine mit Plätzen, Team und Tickets und Workshops. Du wählst sie mit der Prop variant, zum Beispiel variant="sessions".
Kann ich die Texte von Anmeldung übersetzen?
Ja. Anmeldung bringt 46 eigene Texte auf Deutsch und Englisch mit, etwa „Tickets“, „Name“ und „Vorname“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Anmeldung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Anmeldung mit aiw_components und installieren sie mit aiw_add @ai-website/registration. Nenn im Prompt die Variante, etwa variant="attendees", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Anmeldung ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Registration-Code zum Kopieren.