Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Mehrere Personen

Die Ticketzahl erzeugt einen Block je Person, neue steigen auf. Die erste übernimmt die Angaben der Bestellerin mit einem Haken, Essen wird je Person gewählt, die Summe zählt unten mit.

Im Studio öffnen

Termine mit Plätzen

Jeder Termin zeigt die freien Plätze auf einem Balken, der sich bis zur Belegung füllt, wenige Plätze stehen fett. Ein voller Termin bleibt wählbar, ein Hinweis öffnet sich und der Knopf wird zur Warteliste.

Im Studio öffnen

Team

Teamname, Kategorie und Kapitänin in einer hervorgehobenen Fläche, dann Mitglieder mit Shirtgröße. Punkte zählen bis zum Minimum und werden zu „startklar“, eine neue Mitgliedszeile bekommt den Fokus.

Im Studio öffnen

Tickets und Workshops

Ticketarten als Karten, ein ermäßigtes Ticket öffnet ein Feld für den Nachweis. Workshops stehen in ihren Zeitfenstern, zwei gleichzeitige markieren das Fenster und sagen, welche sich überschneiden.

Im Studio öffnen

Details

Was Anmeldung mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle RegistrationProps aus components/ui/registration.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    RegistrationVariant

    attendeessessionsteamtickets

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • eventoptional
    { name: string; date?: string; place?: string; icon?: IconName }

    The event in a short card: name, date, place

  • priceoptional
    number

    attendees: price per ticket, most tickets, diet choices

  • maxTicketsoptional
    number
  • dietsoptional
    string[]
  • sessionsoptional
    RegistrationSession[]

    sessions

  • minMembersoptional
    number

    team: members needed and allowed, categories, shirt sizes

  • maxMembersoptional
    number
  • categoriesoptional
    string[]
  • sizesoptional
    string[]
  • ticketsoptional
    RegistrationTicket[]

    tickets

  • workshopsoptional
    RegistrationWorkshop[]
  • currencyoptional
    string
  • stepsoptional
    DoneStep[]
  • submitLabeloptional
    string
  • doneoptional
    { 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
ticketsEnglisch TicketsDeutsch Tickets
nameEnglisch NameDeutsch Name
firstNameEnglisch First nameDeutsch Vorname
lastNameEnglisch Last nameDeutsch Nachname
nameNeededEnglisch Please add a name, it goes on the ticket.Deutsch Bitte gib einen Namen an, er steht auf dem Ticket.
emailEnglisch EmailDeutsch E-Mail
emailNeededEnglisch 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.
attendeeEnglisch Participant {n}Deutsch Person {n}
sameEnglisch This is meDeutsch Das bin ich
dietEnglisch FoodDeutsch Essen
totalEnglisch TotalDeutsch Gesamt
fromPriceEnglisch from {p}Deutsch ab {p}
sessionEnglisch Choose a dateDeutsch Wähle einen Termin
sessionNeededEnglisch Please choose a date, so we can hold a seat for you.Deutsch Bitte wähle einen Termin, damit wir dir einen Platz freihalten.
leftEnglisch {n} of {total} seats leftDeutsch Noch {n} von {total} Plätzen
fullEnglisch Full, waitlist openDeutsch Voll, Warteliste offen
waitlistNoteEnglisch 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.
teamNameEnglisch Team nameDeutsch Teamname
teamNeededEnglisch Please name your team, it goes on the start list.Deutsch Bitte gib eurem Team einen Namen, er steht auf der Startliste.
categoryEnglisch CategoryDeutsch Kategorie
categoryNeededEnglisch Please choose a category, it decides your start time.Deutsch Bitte wähle eine Kategorie, sie bestimmt eure Startzeit.
captainEnglisch CaptainDeutsch Kapitänin oder Kapitän
membersEnglisch Team membersDeutsch Teammitglieder
memberEnglisch Member {n}Deutsch Mitglied {n}
addMemberEnglisch Add a memberDeutsch Mitglied hinzufügen
removeMemberEnglisch Remove member {n}Deutsch Mitglied {n} entfernen
membersCountEnglisch {n} of {min} neededDeutsch {n} von {min} nötig
membersReadyEnglisch {n} members, ready to startDeutsch {n} Mitglieder, startklar
membersNeededEnglisch Please add at least {n} members, the rules ask for it.Deutsch Bitte trage mindestens {n} Mitglieder ein, das verlangen die Regeln.
sizeEnglisch Shirt sizeDeutsch Shirtgröße
ticketEnglisch TicketDeutsch Ticket
ticketNeededEnglisch Please choose a ticket.Deutsch Bitte wähle ein Ticket.
proofEnglisch ProofDeutsch Nachweis
proofNeededEnglisch 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.
workshopsEnglisch WorkshopsDeutsch Workshops
workshopsHintEnglisch One per slot, workshops in the same slot run at the same timeDeutsch Einer je Zeitfenster, Workshops im selben Fenster laufen gleichzeitig
clashEnglisch {a} and {b} run at the same time. Please keep one of them.Deutsch {a} und {b} laufen gleichzeitig. Bitte behalte einen davon.
slotEnglisch Slot {s}Deutsch Um {s} Uhr
consentEnglisch I agree that my details are used to run this event.Deutsch Ich bin einverstanden, dass meine Angaben für diese Veranstaltung genutzt werden.
consentNeededEnglisch Please agree so we are allowed to register you.Deutsch Bitte stimme zu, damit wir dich anmelden dürfen.
sendEnglisch RegisterDeutsch Anmelden
sendWaitEnglisch Join the waitlistDeutsch Auf die Warteliste
doneTitleEnglisch You are registeredDeutsch Du bist angemeldet
doneTextEnglisch Your confirmation and tickets are in your inbox.Deutsch Bestätigung und Tickets liegen in deinem Postfach.
waitTitleEnglisch You are on the waitlistDeutsch Du stehst auf der Warteliste
againEnglisch Register someone elseDeutsch 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
components/ui/registration.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert