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.
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
Was Webinar-Anmeldung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Events und Webinars
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/webinar-signup.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 33 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- Event
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle WebinarSignupProps aus components/ui/webinar-signup.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalWebinarSignupVariantsplit-registersession-pickerspeaker-stagereplay-gated
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodedateoptional{ day: string; month: string; weekday?: string }split-register, speaker-stage: the date as day, month and weekday
timeoptionalstringHH:MM
durationoptionalstringe.g. 45 min
zoneoptionalstringTime zone as text, e.g. CET
speakersoptionalWebinarSpeaker[]agendaoptionalstring[]What people take away
sessionsoptionalWebinarSession[]session-picker
chaptersoptional{ time: string; title: string }[]replay-gated
mediaoptionalReact.ReactNodereplay-gated: the still before the form, and the player after it
remainingoptionalnumberspeaker-stage: seconds until the start
companyoptionalbooleansplit-register: ask for the company
calendaroptional{ label: string; href: string }ctaoptional{ label: string; href: string }speaker-stage: the main action
whenoptionalstringspeaker-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
formActionoptionalstringregisteredoptionalbooleanStudio preview: start registered
noteoptionalstring
Eingebaute Texte
Diese Texte schreibt Webinar-Anmeldung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 33 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| name | Englisch Full name | Deutsch Vor- und Nachname |
| namePh | Englisch Alex Morgan | Deutsch Alex Morgen |
| Englisch Work email | Deutsch Geschäftliche E-Mail | |
| emailPh | Englisch you@company.com | Deutsch du@firma.de |
| company | Englisch Company | Deutsch Firma |
| optional | Englisch Optional | Deutsch Optional |
| companyPh | Englisch Northwind Studio | Deutsch Studio Nordwind |
| register | Englisch Save my seat | Deutsch Platz sichern |
| registerFor | Englisch Save my seat for {d} | Deutsch Platz am {d} sichern |
| sending | Englisch Saving your seat | Deutsch Platz wird gesichert |
| invalidName | Englisch Please add your name, it goes on the welcome email. | Deutsch Bitte ergänze deinen Namen, er steht in der Willkommensmail. |
| invalidEmail | Englisch Please enter a full email address, like name@company.com. | Deutsch Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de. |
| pickSession | Englisch Please pick a date first. | Deutsch Bitte wähle zuerst einen Termin. |
| failed | Englisch 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. |
| done | Englisch Your seat is saved | Deutsch Dein Platz ist gesichert |
| doneText | Englisch 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. |
| speakers | Englisch Speakers | Deutsch Sprecher |
| learn | Englisch What you take away | Deutsch Was du mitnimmst |
| live | Englisch Live | Deutsch Live |
| free | Englisch Free | Deutsch Kostenlos |
| seats | Englisch {n} seats left | Deutsch Noch {n} Plätze |
| full | Englisch Fully booked | Deutsch Ausgebucht |
| pick | Englisch Pick a date | Deutsch Termin wählen |
| startsIn | Englisch Starts in | Deutsch Beginnt in |
| days | Englisch d | Deutsch Tg. |
| hours | Englisch h | Deutsch Std. |
| minutes | Englisch min | Deutsch Min. |
| seconds | Englisch s | Deutsch Sek. |
| replay | Englisch Watch the recording | Deutsch Aufzeichnung ansehen |
| unlock | Englisch Open the recording | Deutsch Aufzeichnung öffnen |
| chapters | Englisch Chapters | Deutsch Kapitel |
| locked | Englisch Opens after you sign up | Deutsch Öffnet nach der Anmeldung |
| playing | Englisch Enjoy the recording | Deutsch 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
'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.