Zum Inhalt springen
UI Kit
Accent

Webinar-Anmeldung

Anmeldungen für eine Online-Veranstaltung mit den Fakten, die zuerst zählen: Datumsblock und Ablauf neben dem Formular, mehrere Termine mit freien Plätzen, die Sprecher auf dunkler Bühne und eine Aufzeichnung, die sich nach einem kurzen Formular öffnet.

4 VariantsEine von 2 Components in Events und Webinars

Vom Interesse zum Platz im Webinar

Eine Webinar-Anmeldung hat eine Aufgabe, aus Besuchern Teilnehmende mit einem Termin im Kalender zu machen. Die vier Fassungen hier decken einen einzelnen Termin, mehrere Termine, die Sprecher als Zugpferd und die Aufzeichnung nach dem Event ab.

„Details und Formular“ setzt links einen Kalenderblock mit Uhrzeit, Dauer und Live-Hinweis, was man mitnimmt und wer spricht, rechts ein Formular mit Fehlern je Feld, das mit einem Kalenderlink endet. „Termin wählen“ zeigt mehrere Termine als Karten mit der Uhrzeit im Format der Sprache und freien Plätzen, knappe Plätze in warmem Ton, der Knopf nennt den gewählten Termin. Die „Bühne der Sprecher“ zeigt die Sprecher groß auf dunkler Bühne mit weichem Licht hinter jedem Porträt, einem Live-Hinweis und einer Uhr bis zum Start neben dem Hauptknopf. „Aufzeichnung hinter Formular“ lässt die Aufzeichnung unscharf hinter einem Schloss ruhen, daneben die Kapitel, nach einem kurzen Formular wird das Bild klar und die Kapitel werden Knöpfe.

Sag in einer Zeile, was Teilnehmende danach können. Frag bei einem kostenlosen Webinar nur Name und E-Mail ab. Biete allen die Aufzeichnung an, die nicht dabei sein konnten, sie sammelt noch lange nach dem Termin Anmeldungen.

Variants

Details und FormularNeu in 1.2

Ein Kalenderblock mit Uhrzeit, Dauer und Live-Hinweis, links was man mitnimmt und wer spricht, rechts ein Formular mit Fehlern je Feld, das mit einem Kalenderlink endet.

Termin wählenNeu in 1.2

Mehrere Termine als Karten mit der Uhrzeit im Format der Sprache und freien Plätzen, knappe Plätze in warmem Ton, ausgebuchte Termine ruhen. Der Knopf nennt den gewählten Termin.

Bühne der SprecherNeu in 1.2

Die Sprecher groß auf dunkler Bühne mit weichem Licht hinter jedem Porträt und einem Boden aus Linien, oben ein Live-Hinweis und neben dem Hauptknopf eine Uhr bis zum Start.

Aufzeichnung hinter FormularNeu in 1.2

Die Aufzeichnung ruht unscharf hinter einem Schloss, daneben die Kapitel. Nach einem kurzen Formular wird das Bild klar, die Kapitel werden Knöpfe, und das aktuelle steht auf dem Video.

Details

Was Webinar-Anmeldung mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/webinar-signup.tsx
Bringt mit
utilskitbuttoniconsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 33 eigene Texte
Studio
Im Code anpassen
In Sammlungen
Event

Props

Die Schnittstelle WebinarSignupProps aus components/ui/webinar-signup.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    WebinarSignupVariant

    split-registersession-pickerspeaker-stagereplay-gated

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • dateoptional
    { day: string; month: string; weekday?: string }

    split-register, speaker-stage: the date as day, month and weekday

  • timeoptional
    string

    HH:MM

  • durationoptional
    string

    e.g. 45 min

  • zoneoptional
    string

    Time zone as text, e.g. CET

  • speakersoptional
    WebinarSpeaker[]
  • agendaoptional
    string[]

    What people take away

  • sessionsoptional
    WebinarSession[]

    session-picker

  • chaptersoptional
    { time: string; title: string }[]

    replay-gated

  • mediaoptional
    React.ReactNode

    replay-gated: the still before the form, and the player after it

  • remainingoptional
    number

    speaker-stage: seconds until the start

  • companyoptional
    boolean

    split-register: ask for the company

  • calendaroptional
    { label: string; href: string }
  • ctaoptional
    { label: string; href: string }

    speaker-stage: the main action

  • whenoptional
    string

    speaker-stage: date and time as one text, e.g. Thursday, November 20 at 5 PM

  • onRegisteroptional
    (data: WebinarRegistration) => void | Promise<void>

    Resolve to show success, throw to show the error

  • formActionoptional
    string
  • registeredoptional
    boolean

    Studio preview: start registered

  • noteoptional
    string

Eingebaute Texte

Diese Texte schreibt Webinar-Anmeldung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 33 Texte anzeigen
nameEnglisch Full nameDeutsch Vor- und Nachname
namePhEnglisch Alex MorganDeutsch Alex Morgen
emailEnglisch Work emailDeutsch Geschäftliche E-Mail
emailPhEnglisch you@company.comDeutsch du@firma.de
companyEnglisch CompanyDeutsch Firma
optionalEnglisch OptionalDeutsch Optional
companyPhEnglisch Northwind StudioDeutsch Studio Nordwind
registerEnglisch Save my seatDeutsch Platz sichern
registerForEnglisch Save my seat for {d}Deutsch Platz am {d} sichern
sendingEnglisch Saving your seatDeutsch Platz wird gesichert
invalidNameEnglisch Please add your name, it goes on the welcome email.Deutsch Bitte ergänze deinen Namen, er steht in der Willkommensmail.
invalidEmailEnglisch Please enter a full email address, like name@company.com.Deutsch Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de.
pickSessionEnglisch Please pick a date first.Deutsch Bitte wähle zuerst einen Termin.
failedEnglisch That did not go through. Your details are still here, please try again.Deutsch Das hat nicht geklappt. Deine Angaben sind noch da, bitte versuch es noch einmal.
doneEnglisch Your seat is savedDeutsch Dein Platz ist gesichert
doneTextEnglisch The link to join is in your inbox. We send a reminder an hour before.Deutsch Der Link zum Einwählen liegt in deinem Postfach. Eine Stunde vorher erinnern wir dich.
speakersEnglisch SpeakersDeutsch Sprecher
learnEnglisch What you take awayDeutsch Was du mitnimmst
liveEnglisch LiveDeutsch Live
freeEnglisch FreeDeutsch Kostenlos
seatsEnglisch {n} seats leftDeutsch Noch {n} Plätze
fullEnglisch Fully bookedDeutsch Ausgebucht
pickEnglisch Pick a dateDeutsch Termin wählen
startsInEnglisch Starts inDeutsch Beginnt in
daysEnglisch dDeutsch Tg.
hoursEnglisch hDeutsch Std.
minutesEnglisch minDeutsch Min.
secondsEnglisch sDeutsch Sek.
replayEnglisch Watch the recordingDeutsch Aufzeichnung ansehen
unlockEnglisch Open the recordingDeutsch Aufzeichnung öffnen
chaptersEnglisch ChaptersDeutsch Kapitel
lockedEnglisch Opens after you sign upDeutsch Öffnet nach der Anmeldung
playingEnglisch Enjoy the recordingDeutsch Viel Freude mit der Aufzeichnung

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-current, aria-describedby und aria-invalid, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, status und timer.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 619 Zeilen
components/ui/webinar-signup.tsx
'use client';

import { useEffect, useId, useState } from 'react';
import { cn, formatTime, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';

/*
 * A sign-up for one online event, with the facts people check first: when,
 * how long, who speaks, what they take away. Four layouts:
 *
 *   split-register  date block, agenda and speakers beside a full form
 *   session-picker  several dates as cards with seats left, then one field
 *   speaker-stage   the speakers large on a dark stage, a clock to the start
 *   replay-gated    a recording with chapters that opens after a short form
 *
 * Times are written as HH:MM and shown in the format of the language.
 */

export type WebinarSignupVariant = 'split-register' | 'session-picker' | 'speaker-stage' | 'replay-gated';

export interface WebinarSpeaker {
  name: string;
  role?: string;
  /** A portrait, shown round. Without it a monogram is drawn. */
  photo?: React.ReactNode;
}

export interface WebinarSession {
  /** As text in the language, e.g. Tue, Nov 18 */
  date: string;
  /** HH:MM */
  time: string;
  seats?: number;
}

export interface WebinarRegistration {
  name: string;
  email: string;
  company?: string;
  session?: number;
}

export interface WebinarSignupProps {
  variant?: WebinarSignupVariant;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** split-register, speaker-stage: the date as day, month and weekday */
  date?: { day: string; month: string; weekday?: string };
  /** HH:MM */
  time?: string;
  /** e.g. 45 min */
  duration?: string;
  /** Time zone as text, e.g. CET */
  zone?: string;
  speakers?: WebinarSpeaker[];
  /** What people take away */
  agenda?: string[];
  /** session-picker */
  sessions?: WebinarSession[];
  /** replay-gated */
  chapters?: { time: string; title: string }[];
  /** replay-gated: the still before the form, and the player after it */
  media?: React.ReactNode;
  /** speaker-stage: seconds until the start */
  remaining?: number;
  /** split-register: ask for the company */
  company?: boolean;
  calendar?: { label: string; href: string };
  /** speaker-stage: the main action */
  cta?: { label: string; href: string };
  /** speaker-stage: date and time as one text, e.g. Thursday, November 20 at 5 PM */
  when?: string;
  /** Resolve to show success, throw to show the error */
  onRegister?: (data: WebinarRegistration) => void | Promise<void>;
  formAction?: string;
  /** Studio preview: start registered */
  registered?: boolean;
  note?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    name: 'Full name',
    namePh: 'Alex Morgan',
    email: 'Work email',
    emailPh: 'you@company.com',
    company: 'Company',
    optional: 'Optional',
    companyPh: 'Northwind Studio',
    register: 'Save my seat',
    registerFor: 'Save my seat for {d}',
    sending: 'Saving your seat',
    invalidName: 'Please add your name, it goes on the welcome email.',
    invalidEmail: 'Please enter a full email address, like name@company.com.',
    pickSession: 'Please pick a date first.',
    failed: 'That did not go through. Your details are still here, please try again.',
    done: 'Your seat is saved',
    doneText: 'The link to join is in your inbox. We send a reminder an hour before.',
    speakers: 'Speakers',
    learn: 'What you take away',
    live: 'Live',
    free: 'Free',
    seats: '{n} seats left',
    full: 'Fully booked',
    pick: 'Pick a date',
    startsIn: 'Starts in',
    days: 'd',
    hours: 'h',
    minutes: 'min',
    seconds: 's',
    replay: 'Watch the recording',
    unlock: 'Open the recording',
    chapters: 'Chapters',
    locked: 'Opens after you sign up',
    playing: 'Enjoy the recording',
  },
  de: {
    name: 'Vor- und Nachname',
    namePh: 'Alex Morgen',
    email: 'Geschäftliche E-Mail',
    emailPh: 'du@firma.de',
    company: 'Firma',
    optional: 'Optional',
    companyPh: 'Studio Nordwind',
    register: 'Platz sichern',
    registerFor: 'Platz am {d} sichern',
    sending: 'Platz wird gesichert',
    invalidName: 'Bitte ergänze deinen Namen, er steht in der Willkommensmail.',
    invalidEmail: 'Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de.',
    pickSession: 'Bitte wähle zuerst einen Termin.',
    failed: 'Das hat nicht geklappt. Deine Angaben sind noch da, bitte versuch es noch einmal.',
    done: 'Dein Platz ist gesichert',
    doneText: 'Der Link zum Einwählen liegt in deinem Postfach. Eine Stunde vorher erinnern wir dich.',
    speakers: 'Sprecher',
    learn: 'Was du mitnimmst',
    live: 'Live',
    free: 'Kostenlos',
    seats: 'Noch {n} Plätze',
    full: 'Ausgebucht',
    pick: 'Termin wählen',
    startsIn: 'Beginnt in',
    days: 'Tg.',
    hours: 'Std.',
    minutes: 'Min.',
    seconds: 'Sek.',
    replay: 'Aufzeichnung ansehen',
    unlock: 'Aufzeichnung öffnen',
    chapters: 'Kapitel',
    locked: 'Öffnet nach der Anmeldung',
    playing: 'Viel Freude mit der Aufzeichnung',
  },
};

type Texts = (typeof TEXTS)['en'];
type ViewProps = WebinarSignupProps & { t: Texts };

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const FIELD =
  'h-12 w-full min-w-0 rounded-lg border bg-background px-4 text-base text-foreground placeholder:text-subtle-foreground transition-[border-color,box-shadow] duration-200 hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:text-[15px]';
const initials = (n: string) =>
  n
    .split(/\s+/)
    .filter((w) => /^[A-ZÄÖÜ]/.test(w))
    .map((w) => w[0])
    .slice(-2)
    .join('');

function Face({ s, className }: { s: WebinarSpeaker; className?: string }) {
  return (
    <span data-clip-ok className={cn('grid shrink-0 place-items-center overflow-hidden rounded-full border-2 border-background bg-[color-mix(in_oklab,var(--primary)_22%,var(--card))] text-[13px] font-semibold text-foreground [&>*]:size-full [&>*]:object-cover', className)} aria-hidden>
      {s.photo ?? initials(s.name)}
    </span>
  );
}

function FormError({ id, children }: { id: string; children: React.ReactNode }) {
  return (
    <p id={id} role="alert" className="mt-2 flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive">
      <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
      <span>{children}</span>
    </p>
  );
}

function Done({ t, calendar, className }: { t: Texts; calendar?: WebinarSignupProps['calendar']; className?: string }) {
  return (
    <div role="status" className={cn('kit-rise text-center', className)}>
      <span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success">
        <Icon name="calendar-check" size={24} />
      </span>
      <p className="mt-4 text-[19px] font-semibold text-foreground">{t.done}</p>
      <p className="mx-auto mt-2 max-w-[22rem] text-pretty text-[14.5px] text-muted-foreground">{t.doneText}</p>
      {calendar && (
        <Button href={calendar.href} variant="outline" iconLeft="calendar" className="mt-5 max-sm:w-full">
          {calendar.label}
        </Button>
      )}
    </div>
  );
}

/** Name, email and an optional company, with errors per field */
function RegisterForm({ t, p, session, submitLabel, onDone, needSession }: { t: Texts; p: WebinarSignupProps; session?: number; submitLabel: string; onDone: () => void; needSession?: boolean }) {
  const id = useId();
  const [errors, setErrors] = useState<Partial<Record<'name' | 'email' | 'session', string>>>({});
  const [state, setState] = useState<'idle' | 'sending' | 'failed'>('idle');
  return (
    <form
      action={p.formAction}
      method="post"
      noValidate
      className="grid gap-4"
      onSubmit={async (e) => {
        const f = new FormData(e.currentTarget);
        const data: WebinarRegistration = { name: String(f.get('name') ?? '').trim(), email: String(f.get('email') ?? '').trim(), company: String(f.get('company') ?? '').trim() || undefined, session };
        const next: typeof errors = {};
        if (needSession && session === undefined) next.session = t.pickSession;
        if (!data.name) next.name = t.invalidName;
        if (!EMAIL.test(data.email)) next.email = t.invalidEmail;
        setErrors(next);
        const first = (['name', 'email'] as const).find((k) => next[k]);
        if (Object.keys(next).length) {
          e.preventDefault();
          if (first) (e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus();
          return;
        }
        if (!p.onRegister && p.formAction) return;
        e.preventDefault();
        setState('sending');
        try {
          await (p.onRegister ? p.onRegister(data) : new Promise((r) => setTimeout(r, 800)));
          onDone();
        } catch {
          setState('failed');
        }
      }}
    >
      {session !== undefined && <input type="hidden" name="session" value={session} />}
      <div>
        <label htmlFor={`${id}-n`} className="text-sm font-medium text-foreground">
          {t.name}
        </label>
        <input id={`${id}-n`} name="name" autoComplete="name" placeholder={t.namePh} aria-invalid={Boolean(errors.name) || undefined} aria-describedby={errors.name ? `${id}-ne` : undefined} onInput={() => errors.name && setErrors((x) => ({ ...x, name: undefined }))} className={cn(FIELD, 'mt-2', errors.name ? 'border-destructive' : 'border-border')} />
        {errors.name && <FormError id={`${id}-ne`}>{errors.name}</FormError>}
      </div>
      <div>
        <label htmlFor={`${id}-e`} className="text-sm font-medium text-foreground">
          {t.email}
        </label>
        <input id={`${id}-e`} name="email" type="email" inputMode="email" autoComplete="email" placeholder={t.emailPh} aria-invalid={Boolean(errors.email) || undefined} aria-describedby={errors.email ? `${id}-ee` : undefined} onInput={() => errors.email && setErrors((x) => ({ ...x, email: undefined }))} className={cn(FIELD, 'mt-2', errors.email ? 'border-destructive' : 'border-border')} />
        {errors.email && <FormError id={`${id}-ee`}>{errors.email}</FormError>}
      </div>
      {p.company && (
        <div>
          <label htmlFor={`${id}-c`} className="inline-flex items-baseline gap-1.5 text-sm font-medium text-foreground">
            {t.company}
            <span className="text-xs font-normal text-subtle-foreground">{t.optional}</span>
          </label>
          <input id={`${id}-c`} name="company" autoComplete="organization" placeholder={t.companyPh} className={cn(FIELD, 'mt-2 border-border')} />
        </div>
      )}
      {errors.session && <FormError id={`${id}-se`}>{errors.session}</FormError>}
      <Button type="submit" size="lg" disabled={state === 'sending'} icon={state === 'sending' ? undefined : 'arrow-right'} className="mt-1 w-full">
        {state === 'sending' && <Icon name="loader" size={17} className="animate-kit-spin [--kit-duration:0.8s]" />}
        <span className="truncate">{state === 'sending' ? t.sending : submitLabel}</span>
      </Button>
      {state === 'failed' && <FormError id={`${id}-f`}>{t.failed}</FormError>}
      {p.note && <p className="text-center text-[13px] text-muted-foreground">{p.note}</p>}
    </form>
  );
}

function Head({ eyebrow, title, description, className }: Pick<WebinarSignupProps, 'eyebrow' | 'title' | 'description'> & { className?: string }) {
  return (
    <div className={cn('max-w-[40rem]', className)}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
      {description && (
        <p className="kit-rise mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={delay(80)}>
          {description}
        </p>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* split-register                                                      */
/* ------------------------------------------------------------------ */

function SplitRegister(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const [done, setDone] = useState(Boolean(p.registered));
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,27rem)] lg:gap-20">
        <div>
          <div className="kit-rise flex items-center gap-4">
            {p.date && (
              <span className="flex w-[4.5rem] shrink-0 flex-col items-center overflow-hidden rounded-xl border border-border bg-card text-center shadow-[var(--highlight),var(--shadow-sm)]">
                <span className="w-full bg-primary py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-foreground">{p.date.month}</span>
                <span className="py-1.5 text-[28px] font-semibold leading-none tabular-nums tracking-[-0.03em] text-foreground">{p.date.day}</span>
              </span>
            )}
            <span className="grid min-w-0 gap-1 text-[14px] text-muted-foreground">
              {p.date?.weekday && <span className="font-semibold text-foreground">{p.date.weekday}</span>}
              <span className="flex flex-wrap items-center gap-x-3 gap-y-1">
                {p.time && (
                  <span className="inline-flex items-center gap-1.5">
                    <Icon name="clock" size={15} />
                    {formatTime(p.time, locale)} {p.zone}
                  </span>
                )}
                {p.duration && (
                  <span className="inline-flex items-center gap-1.5">
                    <Icon name="hourglass" size={15} />
                    {p.duration}
                  </span>
                )}
                <span className="inline-flex items-center gap-1.5 text-success-foreground">
                  <span className="size-1.5 rounded-full bg-success" aria-hidden />
                  {t.live} · {t.free}
                </span>
              </span>
            </span>
          </div>
          <Head {...p} className="mt-8" />
          {p.agenda?.length ? (
            <div className="mt-10">
              <h3 className="text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.learn}</h3>
              <ol className="mt-4 grid gap-3">
                {p.agenda.map((a, i) => (
                  <li key={i} className="kit-rise flex gap-3.5 text-pretty text-[15.5px] text-foreground" style={delay(140 + i * 60)}>
                    <span className="grid size-7 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[12.5px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{i + 1}</span>
                    <span className="pt-0.5">{a}</span>
                  </li>
                ))}
              </ol>
            </div>
          ) : null}
          {p.speakers?.length ? (
            <div className="mt-10">
              <h3 className="text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.speakers}</h3>
              <ul className="mt-4 grid gap-4 sm:flex sm:flex-wrap sm:gap-x-8">
                {p.speakers.map((s) => (
                  <li key={s.name} className="flex items-center gap-3">
                    <Face s={s} className="size-12" />
                    <span>
                      <span className="block text-[15px] font-semibold text-foreground">{s.name}</span>
                      {s.role && <span className="block text-[13px] text-muted-foreground">{s.role}</span>}
                    </span>
                  </li>
                ))}
              </ul>
            </div>
          ) : null}
        </div>
        <div className="kit-rise lg:sticky lg:top-24 lg:self-start" style={delay(160)}>
          <div className="rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
            {done ? <Done t={t} calendar={p.calendar} className="py-4" /> : <RegisterForm t={t} p={p} submitLabel={t.register} onDone={() => setDone(true)} />}
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* session-picker                                                      */
/* ------------------------------------------------------------------ */

function SessionPicker(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const id = useId();
  const sessions = p.sessions ?? [];
  const firstOpen = sessions.findIndex((s) => s.seats === undefined || s.seats > 0);
  const [pick, setPick] = useState<number | undefined>(firstOpen >= 0 ? firstOpen : undefined);
  const [done, setDone] = useState(Boolean(p.registered));
  const chosen = pick !== undefined ? sessions[pick] : undefined;
  return (
    <Section background="surface" className={p.className}>
      <Head {...p} className="mx-auto text-center [&_p]:mx-auto" />
      <div className="mx-auto mt-12 max-w-[52rem]">
        {done ? (
          <div className="rounded-2xl border border-border bg-card p-8 shadow-[var(--highlight),var(--shadow-md)]">
            <Done t={t} calendar={p.calendar} />
          </div>
        ) : (
          <>
            <fieldset className="min-w-0">
              <legend className="mb-4 w-full text-center text-[14px] font-semibold text-foreground">
                {t.pick}
                {p.zone && <span className="ml-2 font-normal text-muted-foreground">({p.zone})</span>}
              </legend>
              <div className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-3">
                {sessions.map((s, i) => {
                  const full = s.seats === 0;
                  const on = pick === i;
                  return (
                    <label
                      key={i}
                      className={cn(
                        'kit-rise relative flex cursor-pointer items-center gap-4 rounded-2xl border bg-card p-4 transition-[border-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-primary sm:flex-col sm:items-start sm:p-5',
                        on ? 'border-primary shadow-[var(--highlight),0_0_0_4px_var(--glow),var(--shadow-md)]' : 'border-border shadow-[var(--highlight),var(--shadow-sm)] hover:-translate-y-0.5 hover:border-border-strong motion-reduce:hover:translate-y-0',
                        full && 'cursor-not-allowed opacity-55 hover:translate-y-0',
                      )}
                      style={delay(i * 70)}
                    >
                      <input type="radio" name={`${id}-s`} value={i} checked={on} disabled={full} onChange={() => setPick(i)} className="sr-only" />
                      <span className={cn('grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors sm:absolute sm:right-4 sm:top-4', on ? 'border-primary' : 'border-border-strong')} aria-hidden>
                        <span className={cn('size-2.5 rounded-full bg-primary transition-transform duration-300', on ? 'scale-100' : 'scale-0')} />
                      </span>
                      <span className="min-w-0">
                        <span className="block text-[15px] font-semibold text-foreground">{s.date}</span>
                        <span className="mt-0.5 block text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground sm:mt-2 sm:text-[26px]">{formatTime(s.time, locale)}</span>
                        <span className={cn('mt-1 block text-[13px]', full ? 'text-muted-foreground' : s.seats !== undefined && s.seats <= 10 ? 'font-medium text-warning-foreground' : 'text-muted-foreground')}>
                          {full ? t.full : s.seats !== undefined ? fill(t.seats, { n: s.seats }) : p.duration}
                        </span>
                      </span>
                    </label>
                  );
                })}
              </div>
            </fieldset>
            <div className="kit-rise mx-auto mt-8 max-w-[30rem] rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-7" style={delay(200)}>
              <RegisterForm t={t} p={p} session={pick} needSession submitLabel={chosen ? fill(t.registerFor, { d: chosen.date }) : t.register} onDone={() => setDone(true)} />
            </div>
          </>
        )}
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* speaker-stage                                                       */
/* ------------------------------------------------------------------ */

function useClock(start?: number) {
  const [left, setLeft] = useState(Math.max(0, start ?? 0));
  useEffect(() => {
    if (start === undefined) return;
    const t0 = Date.now();
    const id = window.setInterval(() => setLeft(Math.max(0, start - Math.floor((Date.now() - t0) / 1000))), 1000);
    return () => window.clearInterval(id);
  }, [start]);
  return left;
}

function SpeakerStage(p: ViewProps) {
  const { t } = p;
  const left = useClock(p.remaining);
  const parts = [Math.floor(left / 86400), Math.floor((left % 86400) / 3600), Math.floor((left % 3600) / 60), left % 60];
  const units = [t.days, t.hours, t.minutes, t.seconds];
  const speakers = p.speakers ?? [];
  return (
    <section className={cn('dark relative overflow-hidden bg-background py-24 text-foreground sm:py-32', p.className)}>
      <div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-[radial-gradient(60%_70%_at_50%_100%,var(--glow),transparent_70%)]" />
      {/* a stage floor of light lines */}
      <div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 [background:repeating-linear-gradient(to_right,transparent_0_79px,color-mix(in_oklab,var(--foreground)_10%,transparent)_79px_80px)] [mask-image:linear-gradient(to_top,black,transparent)] [transform:perspective(400px)_rotateX(60deg)] [transform-origin:bottom]" />
      <div className="kit-container relative">
        <div className="mx-auto max-w-[44rem] text-center">
          <p className="kit-rise inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-[13px] font-medium text-foreground">
            <span className="relative flex size-2">
              <span className="absolute inset-0 animate-ping rounded-full bg-destructive opacity-60 motion-reduce:animate-none" />
              <span className="relative size-2 rounded-full bg-destructive" />
            </span>
            {t.live}
            {p.when && <span className="text-muted-foreground">· {p.when}</span>}
          </p>
          <h2 className="kit-rise mt-6 text-balance text-h1 font-semibold text-foreground" style={delay(60)}>
            {p.title}
          </h2>
          {p.description && (
            <p className="kit-rise mx-auto mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={delay(120)}>
              {p.description}
            </p>
          )}
        </div>
        <ul className={cn('mx-auto mt-14 grid max-w-[60rem] grid-cols-2 gap-6 sm:gap-8 [&>:last-child:nth-child(odd)]:col-span-2', speakers.length >= 3 && 'md:[&>:last-child:nth-child(odd)]:col-span-1', speakers.length >= 3 && 'md:grid-cols-3', speakers.length >= 4 && 'lg:grid-cols-4')}>
          {speakers.map((s, i) => (
            <li key={s.name} className="kit-rise group/sp flex flex-col items-center text-center" style={delay(180 + i * 90)}>
              <span className="relative">
                <span aria-hidden className="absolute -inset-2 rounded-full bg-[conic-gradient(from_0deg,var(--primary),transparent_40%,var(--primary))] opacity-40 blur-md transition-opacity duration-500 group-hover/sp:opacity-80" />
                <Face s={s} className="relative size-28 border-4 text-[26px] sm:size-36 sm:text-[32px]" />
              </span>
              <span className="mt-5 text-[16px] font-semibold text-foreground">{s.name}</span>
              {s.role && <span className="mt-1 text-pretty text-[13.5px] text-muted-foreground">{s.role}</span>}
            </li>
          ))}
        </ul>
        <div className="kit-rise mx-auto mt-14 flex max-w-[36rem] flex-col items-center gap-6 rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:flex-row sm:justify-between sm:p-6" style={delay(400)}>
          <div className="text-center sm:text-left">
            <p className="text-[12.5px] text-muted-foreground">{t.startsIn}</p>
            <p role="timer" className="mt-1 flex items-baseline justify-center gap-2 tabular-nums sm:justify-start">
              {parts.map((v, i) => (
                <span key={i} className="inline-flex items-baseline gap-0.5">
                  <span className="text-[26px] font-semibold tracking-[-0.03em] text-foreground">{String(v).padStart(2, '0')}</span>
                  <span className="text-[12px] text-muted-foreground">{units[i]}</span>
                </span>
              ))}
            </p>
          </div>
          {p.cta ? (
            <Button href={p.cta.href} size="lg" icon="arrow-right" className="max-sm:w-full">
              {p.cta.label}
            </Button>
          ) : null}
        </div>
        {p.agenda?.length ? (
          <ul className="mx-auto mt-10 flex max-w-[52rem] flex-wrap justify-center gap-2">
            {p.agenda.map((a, i) => (
              <li key={i} className="rounded-full border border-border bg-[color-mix(in_oklab,var(--card)_70%,transparent)] px-4 py-2 text-[13.5px] text-muted-foreground">
                {a}
              </li>
            ))}
          </ul>
        ) : null}
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* replay-gated                                                        */
/* ------------------------------------------------------------------ */

function ReplayGated(p: ViewProps) {
  const { t } = p;
  const [open, setOpen] = useState(Boolean(p.registered));
  const [chapter, setChapter] = useState(0);
  const chapters = p.chapters ?? [];
  return (
    <Section className={p.className}>
      <Head {...p} />
      <div className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)]">
        <div className="kit-rise relative aspect-video overflow-hidden rounded-2xl border border-border bg-bezel shadow-[var(--highlight),var(--shadow-lg)] [&>*:first-child]:size-full" style={delay(120)}>
          {p.media}
          {!open && (
            <div className="absolute inset-0 grid place-items-center bg-[color-mix(in_oklab,var(--background)_45%,transparent)] backdrop-blur-[6px]">
              <div className="flex flex-col items-center gap-3 text-center">
                <span className="grid size-16 place-items-center rounded-full border border-border bg-card text-foreground shadow-[var(--shadow-lg)]">
                  <Icon name="lock" size={24} />
                </span>
                <p className="rounded-full bg-card px-3 py-1 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)]">{t.locked}</p>
              </div>
            </div>
          )}
          {open && (
            <p role="status" className="kit-rise absolute left-3 top-3 inline-flex items-center gap-2 rounded-full bg-card px-3 py-1.5 text-[13px] font-medium text-foreground shadow-[var(--shadow-md)]">
              <Icon name="play" size={13} className="text-primary" />
              {chapters[chapter]?.title ?? t.playing}
            </p>
          )}
        </div>
        <div className="kit-rise rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]" style={delay(180)}>
          {open ? (
            <div className="p-2">
              <p className="px-3 pb-2 pt-3 text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.chapters}</p>
              <ol>
                {chapters.map((c, i) => (
                  <li key={i}>
                    <button
                      type="button"
                      aria-current={chapter === i ? 'true' : undefined}
                      onClick={() => setChapter(i)}
                      className={cn('flex min-h-12 w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-left transition-colors focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary', chapter === i ? 'bg-accent' : 'hover:bg-surface')}
                    >
                      <span className={cn('w-12 shrink-0 text-[13px] font-medium tabular-nums', chapter === i ? 'text-primary' : 'text-muted-foreground')}>{c.time}</span>
                      <span className="min-w-0 flex-1 text-pretty text-[14.5px] font-medium text-foreground">{c.title}</span>
                      {chapter === i && <Icon name="activity" size={15} className="shrink-0 text-primary" />}
                    </button>
                  </li>
                ))}
              </ol>
            </div>
          ) : (
            <div className="p-6">
              <p className="text-[16px] font-semibold text-foreground">{t.replay}</p>
              <ul className="mb-5 mt-3 grid gap-1.5">
                {chapters.slice(0, 4).map((c, i) => (
                  <li key={i} className="flex gap-3 text-[13.5px] text-muted-foreground">
                    <span className="w-10 shrink-0 tabular-nums">{c.time}</span>
                    <span className="min-w-0 truncate">{c.title}</span>
                  </li>
                ))}
              </ul>
              <RegisterForm t={t} p={p} submitLabel={t.unlock} onDone={() => setOpen(true)} />
            </div>
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */

const VIEWS: Record<WebinarSignupVariant, (p: ViewProps) => React.ReactNode> = {
  'split-register': SplitRegister,
  'session-picker': SessionPicker,
  'speaker-stage': SpeakerStage,
  'replay-gated': ReplayGated,
};

export function WebinarSignup({ variant = 'split-register', labels, ...rest }: WebinarSignupProps) {
  const t = useTexts(TEXTS, labels);
  const View = VIEWS[variant] ?? SplitRegister;
  return <View {...rest} variant={variant} t={t} />;
}

Häufige Fragen

Wie installiere ich Webinar-Anmeldung?

Mit aiw add @ai-website/webinar-signup. Der Befehl legt die Datei components/ui/webinar-signup.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Webinar-Anmeldung?

4 Varianten, Details und Formular, Termin wählen, Bühne der Sprecher und Aufzeichnung hinter Formular. Du wählst sie mit der Prop variant, zum Beispiel variant="session-picker".

Kann ich die Texte von Webinar-Anmeldung übersetzen?

Ja. Webinar-Anmeldung bringt 33 eigene Texte auf Deutsch und Englisch mit, etwa „Vor- und Nachname“, „Alex Morgen“ und „Geschäftliche E-Mail“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Webinar-Anmeldung einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Webinar-Anmeldung mit aiw_components und installieren sie mit aiw_add @ai-website/webinar-signup. Nenn im Prompt die Variante, etwa variant="split-register", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert