Zum Inhalt springen
UI Kit
Accent

Warteliste für die Landingpage

Wartelisten, die einen Grund zum Bleiben geben: ein Platz in der Reihe mit Belohnungen fürs Einladen, ein kurzes Formular, eine Menge um ein Feld und ein Panel für Einladungscodes.

4 VariantsEine von 3 Components in Launch und Waitlist

Ein Grund zu warten und zu teilen

Eine Warteliste macht aus Interesse vor dem Launch eine Liste von Menschen, die dabei sein wollen. Die vier Fassungen hier geben einen Grund zu bleiben und andere mitzubringen, vom sichtbaren Platz in der Reihe bis zum Code, der die Tür sofort öffnet.

„Platz in der Reihe mit Einladungen“ braucht ein Feld, danach zeigt sie den eigenen Platz, einen Einladungslink zum Kopieren und die Stufen zum Hochklettern, die Leiste füllt sich mit jeder Einladung. „Vorteile und kurzes Formular“ stellt links, was frühe Mitglieder bekommen, und rechts ein kurzes Formular mit Name, E-Mail, Rolle und Teamgröße, jedes Feld sagt, was fehlt, der Erfolg zeigt Platz und Termin der nächsten Gruppe. „Menge um ein Feld“ setzt eine große Überschrift und ein Feld in die Mitte, rundherum schweben die Monogramme der Wartenden, die Zahl steigt beim Eintragen. „Code oder Anfrage“ löst einen Code in Feldern ein, die Einfügen annehmen und mit den Pfeiltasten wandern, oder fragt den Zugang mit kurzer Notiz an.

Frag nur, was du wirklich brauchst, ein einziges Feld bringt die meisten Anmeldungen. Zeig den Platz gleich nach dem Eintragen, in diesem Moment teilen Menschen. Schreib den nächsten Schritt klar hin, wann die nächste Gruppe dran ist und wie man davon erfährt.

Variants

Platz in der Reihe mit EinladungenNeu in 1.2

Mit einem Feld eintragen, dann den eigenen Platz sehen, den Einladungslink kopieren und die Stufen hochklettern. Der Platz bleibt unscharf, bis du drin bist, die Leiste füllt sich mit jeder Einladung.

Vorteile und kurzes FormularNeu in 1.2

Was frühe Mitglieder bekommen links, rechts ein kurzes Formular mit Name, E-Mail, Rolle und Teamgröße. Jedes Feld sagt, was fehlt, der Erfolg zeigt den Platz und den Termin der nächsten Gruppe.

Menge um ein FeldNeu in 1.2

Eine große Überschrift und ein Feld in der Mitte, rundherum schweben die Monogramme der Wartenden. Eine kleine Zeile sagt, wer gerade dazukam, die Zahl steigt, wenn du dich einträgst.

Code oder AnfrageNeu in 1.2

Ein Panel, zwei Wege hinein. Einen Code in Feldern einlösen, die Einfügen annehmen, mit den Pfeiltasten wandern und genau sagen, was fehlt, oder den Zugang mit kurzer Notiz anfragen.

Details

Was Warteliste mitbringt, aus Registry und Quellcode gelesen.

Bereich
Marketing
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/waitlist.tsx
Bringt mit
utilskitmotionbuttoniconnumber-tickersection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 42 eigene Texte
Studio
Im Code anpassen

Props

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

  • variantoptional
    WaitlistVariant

    referral-queuesplit-formcrowdinvite-code

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • countoptional
    number

    People already waiting, counted up in the language format

  • positionoptional
    number

    referral-queue, split-form: the place a new sign-up gets

  • referraloptional
    { url: string; rewards: WaitlistReward[]; placesPerInvite?: number }

    referral-queue: the link people share, and what each number of invites unlocks

  • perksoptional
    WaitlistPerk[]

    split-form: perks with icons

  • rolesoptional
    string[]

    split-form: the role picker

  • sizesoptional
    string[]

    split-form: team sizes as chips

  • peopleoptional
    { name: string; place?: string }[]

    crowd: first names (or initials) of people who just joined, shown as monograms and in the feed

  • codeLengthoptional
    number

    invite-code: number of characters in a code

  • onRedeemoptional
    (code: string) => boolean | Promise<boolean>

    invite-code: checks a code, resolve true to accept. Without it any full code is accepted.

  • noteoptional
    string

    Short reassurance under the form

  • nextBatchoptional
    string

    split-form: when the next group gets in, e.g. Nov 18

  • formActionoptional
    string
  • onSubmitoptional
    (data: WaitlistData) => void | Promise<void>

    Resolve to show success, throw to show the error

  • invitesoptional
    number

    referral-queue: invites this person has sent that joined

  • joinedoptional
    boolean

    Studio preview: start in the joined state

Eingebaute Texte

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

Alle 42 Texte anzeigen
emailEnglisch Email addressDeutsch E-Mail-Adresse
emailPhEnglisch you@company.comDeutsch du@firma.de
nameEnglisch Your nameDeutsch Dein Name
namePhEnglisch Alex MorganDeutsch Alex Morgen
roleEnglisch What do you do?Deutsch Was machst du beruflich?
rolePickEnglisch Pick your roleDeutsch Rolle wählen
sizeEnglisch Team sizeDeutsch Teamgröße
joinEnglisch Join the waitlistDeutsch Auf die Warteliste
joiningEnglisch Saving your placeDeutsch Platz wird gesichert
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.
invalidNameEnglisch Please tell us your name, so the invite is addressed to you.Deutsch Bitte nenn uns deinen Namen, damit die Einladung dich persönlich erreicht.
invalidRoleEnglisch Please pick the role closest to yours.Deutsch Bitte wähle die Rolle, die deiner am nächsten kommt.
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.
waitingEnglisch people waitingDeutsch Menschen warten
yourPlaceEnglisch Your place in lineDeutsch Dein Platz in der Reihe
youAreInEnglisch You are on the listDeutsch Du stehst auf der Liste
shareEnglisch Your invite linkDeutsch Dein Einladungslink
copyEnglisch Copy linkDeutsch Link kopieren
copiedEnglisch CopiedDeutsch Kopiert
moveUpEnglisch Every friend who joins moves you up {n} places.Deutsch Jede Person, die über dich beitritt, bringt dich {n} Plätze nach vorn.
invitesEnglisch {n} invitesDeutsch {n} Einladungen
invite1Englisch 1 inviteDeutsch 1 Einladung
unlockedEnglisch UnlockedDeutsch Freigeschaltet
ladderEnglisch Move up the lineDeutsch In der Reihe vorrücken
ladderLockedEnglisch Join to get your link and start moving up.Deutsch Trag dich ein, dann bekommst du deinen Link und rückst vor.
nextBatchEnglisch Next group gets in on {date}. We email you the day before.Deutsch Die nächste Gruppe kommt am {date} rein. Wir schreiben dir am Tag davor.
justJoinedEnglisch just joinedDeutsch ist gerade dazugekommen
fromEnglisch fromDeutsch aus
requestEnglisch Request accessDeutsch Zugang anfragen
redeemEnglisch I have a codeDeutsch Ich habe einen Code
codeEnglisch Invite codeDeutsch Einladungscode
codeHintEnglisch The code is in your invite email, letters and digits.Deutsch Der Code steht in deiner Einladung, Buchstaben und Ziffern.
checkEnglisch Unlock accessDeutsch Zugang freischalten
checkingEnglisch Checking the codeDeutsch Code wird geprüft
codeShortEnglisch The code has {n} characters. Please fill in every box.Deutsch Der Code hat {n} Zeichen. Bitte füll jedes Feld aus.
codeWrongEnglisch That code does not open anything yet. Check the email or request access below.Deutsch Dieser Code öffnet noch nichts. Schau in die E-Mail oder frag unten den Zugang an.
codeOkEnglisch Welcome in. Your account is ready.Deutsch Willkommen. Dein Konto ist bereit.
openEnglisch Open my accountDeutsch Mein Konto öffnen
whyEnglisch What would you use it for?Deutsch Wofür würdest du es nutzen?
whyOptionalEnglisch A sentence helps us invite the right people first.Deutsch Ein Satz hilft uns, die Richtigen zuerst einzuladen.
sentEnglisch Thanks. We read every request and answer within a week.Deutsch Danke. Wir lesen jede Anfrage und antworten innerhalb einer Woche.
waysEnglisch Two ways inDeutsch Zwei Wege hinein

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Warteliste reagiert selbst auf die Tasten Pfeil links, Pfeil rechts, Rücktaste und Leertaste.
  • Setzt aria-controls, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, group, status, tab, tablist und tabpanel.
  • 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. 6 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 914 Zeilen
components/ui/waitlist.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Magnetic } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Eyebrow, Section } from '@/components/ui/section';

/*
 * A waitlist that gives people a reason to stay. Four layouts:
 *
 *   referral-queue  join, see your place, move up the line by inviting friends
 *   split-form      perks on the left, a short qualifying form on the right
 *   crowd           one field in the middle, the people already waiting around it
 *   invite-code     request access or redeem a code, in one segmented panel
 *
 * Every form validates in place, says what went wrong in plain words and
 * ends in a success state. Without onSubmit the forms post to formAction.
 */

export type WaitlistVariant = 'referral-queue' | 'split-form' | 'crowd' | 'invite-code';

export interface WaitlistReward {
  /** Invites needed */
  invites: number;
  reward: string;
}

export interface WaitlistPerk {
  icon?: IconName;
  title: string;
  text?: string;
}

export interface WaitlistData {
  email: string;
  name?: string;
  role?: string;
  size?: string;
  code?: string;
}

export interface WaitlistProps {
  variant?: WaitlistVariant;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** People already waiting, counted up in the language format */
  count?: number;
  /** referral-queue, split-form: the place a new sign-up gets */
  position?: number;
  /** referral-queue: the link people share, and what each number of invites unlocks */
  referral?: { url: string; rewards: WaitlistReward[]; placesPerInvite?: number };
  /** split-form: perks with icons */
  perks?: WaitlistPerk[];
  /** split-form: the role picker */
  roles?: string[];
  /** split-form: team sizes as chips */
  sizes?: string[];
  /** crowd: first names (or initials) of people who just joined, shown as monograms and in the feed */
  people?: { name: string; place?: string }[];
  /** invite-code: number of characters in a code */
  codeLength?: number;
  /** invite-code: checks a code, resolve true to accept. Without it any full code is accepted. */
  onRedeem?: (code: string) => boolean | Promise<boolean>;
  /** Short reassurance under the form */
  note?: string;
  /** split-form: when the next group gets in, e.g. Nov 18 */
  nextBatch?: string;
  formAction?: string;
  /** Resolve to show success, throw to show the error */
  onSubmit?: (data: WaitlistData) => void | Promise<void>;
  /** referral-queue: invites this person has sent that joined */
  invites?: number;
  /** Studio preview: start in the joined state */
  joined?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    email: 'Email address',
    emailPh: 'you@company.com',
    name: 'Your name',
    namePh: 'Alex Morgan',
    role: 'What do you do?',
    rolePick: 'Pick your role',
    size: 'Team size',
    join: 'Join the waitlist',
    joining: 'Saving your place',
    invalidEmail: 'Please enter a full email address, like name@company.com.',
    invalidName: 'Please tell us your name, so the invite is addressed to you.',
    invalidRole: 'Please pick the role closest to yours.',
    failed: 'That did not go through. Your details are still here, please try again.',
    waiting: 'people waiting',
    yourPlace: 'Your place in line',
    youAreIn: 'You are on the list',
    share: 'Your invite link',
    copy: 'Copy link',
    copied: 'Copied',
    moveUp: 'Every friend who joins moves you up {n} places.',
    invites: '{n} invites',
    invite1: '1 invite',
    unlocked: 'Unlocked',
    ladder: 'Move up the line',
    ladderLocked: 'Join to get your link and start moving up.',
    nextBatch: 'Next group gets in on {date}. We email you the day before.',
    justJoined: 'just joined',
    from: 'from',
    request: 'Request access',
    redeem: 'I have a code',
    code: 'Invite code',
    codeHint: 'The code is in your invite email, letters and digits.',
    check: 'Unlock access',
    checking: 'Checking the code',
    codeShort: 'The code has {n} characters. Please fill in every box.',
    codeWrong: 'That code does not open anything yet. Check the email or request access below.',
    codeOk: 'Welcome in. Your account is ready.',
    open: 'Open my account',
    why: 'What would you use it for?',
    whyOptional: 'A sentence helps us invite the right people first.',
    sent: 'Thanks. We read every request and answer within a week.',
    ways: 'Two ways in',
  },
  de: {
    email: 'E-Mail-Adresse',
    emailPh: 'du@firma.de',
    name: 'Dein Name',
    namePh: 'Alex Morgen',
    role: 'Was machst du beruflich?',
    rolePick: 'Rolle wählen',
    size: 'Teamgröße',
    join: 'Auf die Warteliste',
    joining: 'Platz wird gesichert',
    invalidEmail: 'Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de.',
    invalidName: 'Bitte nenn uns deinen Namen, damit die Einladung dich persönlich erreicht.',
    invalidRole: 'Bitte wähle die Rolle, die deiner am nächsten kommt.',
    failed: 'Das hat nicht geklappt. Deine Angaben sind noch da, bitte versuch es noch einmal.',
    waiting: 'Menschen warten',
    yourPlace: 'Dein Platz in der Reihe',
    youAreIn: 'Du stehst auf der Liste',
    share: 'Dein Einladungslink',
    copy: 'Link kopieren',
    copied: 'Kopiert',
    moveUp: 'Jede Person, die über dich beitritt, bringt dich {n} Plätze nach vorn.',
    invites: '{n} Einladungen',
    invite1: '1 Einladung',
    unlocked: 'Freigeschaltet',
    ladder: 'In der Reihe vorrücken',
    ladderLocked: 'Trag dich ein, dann bekommst du deinen Link und rückst vor.',
    nextBatch: 'Die nächste Gruppe kommt am {date} rein. Wir schreiben dir am Tag davor.',
    justJoined: 'ist gerade dazugekommen',
    from: 'aus',
    request: 'Zugang anfragen',
    redeem: 'Ich habe einen Code',
    code: 'Einladungscode',
    codeHint: 'Der Code steht in deiner Einladung, Buchstaben und Ziffern.',
    check: 'Zugang freischalten',
    checking: 'Code wird geprüft',
    codeShort: 'Der Code hat {n} Zeichen. Bitte füll jedes Feld aus.',
    codeWrong: 'Dieser Code öffnet noch nichts. Schau in die E-Mail oder frag unten den Zugang an.',
    codeOk: 'Willkommen. Dein Konto ist bereit.',
    open: 'Mein Konto öffnen',
    why: 'Wofür würdest du es nutzen?',
    whyOptional: 'Ein Satz hilft uns, die Richtigen zuerst einzuladen.',
    sent: 'Danke. Wir lesen jede Anfrage und antworten innerhalb einer Woche.',
    ways: 'Zwei Wege hinein',
  },
};

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

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
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 wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const initials = (name: string) =>
  name
    .split(/\s+/)
    .map((w) => w[0])
    .slice(0, 2)
    .join('')
    .toUpperCase();
const SHAKE = '@keyframes wl-shake{20%,60%{transform:translateX(-6px)}40%,80%{transform:translateX(6px)}}';
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]';

function useFormat() {
  const locale = useLocale();
  return (n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
}

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

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>
  );
}

/** A small round mark with initials, toned from the accent so a row of them stays calm */
function Monogram({ name, index, className, style }: { name: string; index: number; className?: string; style?: React.CSSProperties }) {
  const mix = [18, 30, 44, 24, 36][index % 5];
  return (
    <span
      aria-hidden
      className={cn('grid size-9 shrink-0 place-items-center rounded-full border-2 border-background text-[11px] font-semibold text-foreground', className)}
      style={{ background: `color-mix(in oklab, var(--primary) ${mix}%, var(--card))`, ...style }}
    >
      {initials(name)}
    </span>
  );
}

function Spinner() {
  return <Icon name="loader" size={17} className="animate-kit-spin [--kit-duration:0.8s]" />;
}

/** One email field and a button, the shape most waitlists need */
function EmailRow({ t, onDone, formAction, onSubmit, big }: { t: Texts; onDone: (email: string) => void; formAction?: string; onSubmit?: WaitlistProps['onSubmit']; big?: boolean }) {
  const id = useId();
  const [state, setState] = useState<'idle' | 'invalid' | 'sending' | 'failed'>('idle');
  const submit = async (e: React.FormEvent<HTMLFormElement>) => {
    const email = String(new FormData(e.currentTarget).get('email') ?? '').trim();
    if (!EMAIL.test(email)) {
      e.preventDefault();
      setState('invalid');
      e.currentTarget.querySelector('input')?.focus();
      return;
    }
    if (!onSubmit && formAction) return;
    e.preventDefault();
    setState('sending');
    try {
      await (onSubmit ? onSubmit({ email }) : wait(800));
      onDone(email);
    } catch {
      setState('failed');
    }
  };
  const bad = state === 'invalid';
  return (
    <form action={formAction} method="post" noValidate onSubmit={submit} className="w-full">
      <style>{SHAKE}</style>
      <label htmlFor={id} className="sr-only">
        {t.email}
      </label>
      <div
        className={cn(
          'flex flex-col gap-2 sm:flex-row sm:rounded-xl sm:border sm:bg-card sm:p-1.5 sm:shadow-[var(--highlight),var(--shadow-md)] sm:transition-[border-color,box-shadow] sm:focus-within:shadow-[0_0_0_4px_var(--glow),var(--shadow-md)]',
          bad ? 'animate-[wl-shake_.4s_var(--ease-kit)] sm:border-destructive motion-reduce:animate-none' : 'sm:border-border sm:focus-within:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))]',
        )}
      >
        <input
          id={id}
          name="email"
          type="email"
          autoComplete="email"
          inputMode="email"
          required
          placeholder={t.emailPh}
          aria-invalid={bad || undefined}
          aria-describedby={bad || state === 'failed' ? `${id}-e` : undefined}
          onInput={() => bad && setState('idle')}
          className={cn(FIELD, 'bg-card sm:h-11 sm:flex-1 sm:border-0 sm:bg-transparent sm:shadow-none sm:focus-visible:shadow-none', big && 'sm:h-12 sm:text-[16px]', bad ? 'border-destructive' : 'border-border')}
        />
        <Button type="submit" size="lg" disabled={state === 'sending'} icon={state === 'sending' ? undefined : 'arrow-right'} className={cn('max-sm:w-full sm:h-11', big && 'sm:h-12')}>
          {state === 'sending' && <Spinner />}
          {state === 'sending' ? t.joining : t.join}
        </Button>
      </div>
      {(bad || state === 'failed') && <FormError id={`${id}-e`}>{bad ? t.invalidEmail : t.failed}</FormError>}
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* referral-queue                                                      */
/* ------------------------------------------------------------------ */

function CopyLink({ url, t }: { url: string; t: Texts }) {
  const id = useId();
  const [copied, setCopied] = useState(false);
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(url);
    } catch {
      /* the field stays selectable */
    }
    setCopied(true);
    window.setTimeout(() => setCopied(false), 1800);
  };
  return (
    <div>
      <label htmlFor={id} className="text-[13px] font-medium text-muted-foreground">
        {t.share}
      </label>
      <div className="mt-2 flex gap-2">
        <input id={id} readOnly value={url} onFocus={(e) => e.currentTarget.select()} className={cn(FIELD, 'h-11 border-border bg-surface text-[14px] sm:text-[14px]')} />
        <button
          type="button"
          onClick={copy}
          className="inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3.5 text-[14px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-[background-color,transform] hover:bg-accent active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary"
        >
          <Icon name={copied ? 'check' : 'copy'} size={16} className={cn(copied && 'text-success')} />
          <span aria-live="polite">{copied ? t.copied : t.copy}</span>
        </button>
      </div>
    </div>
  );
}

function ReferralQueue(p: ViewProps) {
  const { t } = p;
  const format = useFormat();
  const [email, setEmail] = useState<string | null>(p.joined ? 'demo' : null);
  const joined = email !== null;
  const rewards = p.referral?.rewards ?? [];
  const per = p.referral?.placesPerInvite ?? 120;
  const invites = joined ? (p.invites ?? 0) : 0;
    const place = Math.max(1, (p.position ?? 2481) - invites * per);
  const max = rewards.length ? rewards[rewards.length - 1].invites : 1;
  return (
    <Section background="glow" className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] items-center gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,27rem)] lg:gap-20">
        <div>
          <Head {...p} />
          <div className="kit-rise mt-9 max-w-[30rem]" style={delay(160)}>
            {joined ? (
              <div role="status" className="kit-rise flex items-start gap-3 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4 text-[14.5px] text-foreground">
                <Icon name="check-circle" size={20} className="mt-px shrink-0 text-success" />
                <span className="text-pretty">
                  <strong className="font-semibold">{t.youAreIn}.</strong> {fill(t.moveUp, { n: per })}
                </span>
              </div>
            ) : (
              <EmailRow t={t} onDone={setEmail} formAction={p.formAction} onSubmit={p.onSubmit} />
            )}
            {!joined && p.count !== undefined && (
              <p className="mt-4 flex items-center gap-2 text-[13.5px] text-muted-foreground">
                <span className="relative flex size-2" aria-hidden>
                  <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
                  <span className="relative size-2 rounded-full bg-success" />
                </span>
                <span>
                  <NumberTicker value={p.count} className="font-semibold tabular-nums text-foreground" /> {t.waiting}
                </span>
              </p>
            )}
            {p.note && !joined && <p className="mt-2 text-[13px] text-subtle-foreground">{p.note}</p>}
          </div>
        </div>

        {/* the line: your place, your link and the rungs above you */}
        <div className="kit-rise relative" style={delay(220)}>
          <div className="relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
            <div className="border-b border-border p-6 sm:p-7">
              <p className="text-[13px] font-medium text-muted-foreground">{t.yourPlace}</p>
              <p className={cn('mt-2 flex items-baseline gap-1 font-semibold leading-none tracking-[-0.04em] tabular-nums transition-[color,filter] duration-500', joined ? 'text-foreground' : 'text-subtle-foreground blur-[5px] select-none')} aria-hidden={!joined || undefined}>
                <span className="text-[28px] text-subtle-foreground">#</span>
                <span key={place} className={cn('text-[clamp(3rem,2.4rem+2.4vw,4.25rem)]', joined && 'animate-[wl-pop_.5s_var(--ease-kit)] motion-reduce:animate-none')}>
                  {format(place)}
                </span>
              </p>
              <style>{'@keyframes wl-pop{from{opacity:.2;transform:translateY(30%)}}'}</style>
              {joined ? (
                <div className="mt-6">
                  <CopyLink url={p.referral?.url ?? ''} t={t} />
                  <p className="mt-3 flex items-center gap-1.5 text-[13px] text-muted-foreground">
                    <Icon name="user-plus" size={15} />
                    {invites === 1 ? t.invite1 : fill(t.invites, { n: invites })}
                  </p>
                </div>
              ) : (
                <p className="mt-4 text-pretty text-[14px] text-muted-foreground">{t.ladderLocked}</p>
              )}
            </div>
            <div className="p-6 sm:p-7">
              <p className="text-[13px] font-medium text-muted-foreground">{t.ladder}</p>
              <ol className="relative mt-4 grid gap-4">
                <span aria-hidden className="absolute bottom-3 left-[15px] top-3 w-px bg-border" />
                <span aria-hidden className="absolute left-[15px] top-3 w-px origin-top bg-primary transition-transform duration-700 ease-[var(--ease-kit)]" style={{ height: 'calc(100% - 1.5rem)', transform: `scaleY(${Math.min(1, invites / Math.max(1, max))})` }} />
                {rewards.map((r, i) => {
                  const on = joined && invites >= r.invites;
                  return (
                    <li key={i} className="relative flex items-center gap-3.5">
                      <span className={cn('relative z-[1] grid size-[31px] shrink-0 place-items-center rounded-full border text-[12px] font-semibold transition-[background-color,border-color,color] duration-500', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-card text-muted-foreground')}>
                        {on ? <Icon name="check" size={14} strokeWidth={2.2} /> : r.invites}
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="block text-pretty text-[14.5px] font-medium text-foreground">{r.reward}</span>
                        <span className="block text-[12.5px] text-muted-foreground">{on ? t.unlocked : r.invites === 1 ? t.invite1 : fill(t.invites, { n: r.invites })}</span>
                      </span>
                    </li>
                  );
                })}
              </ol>
            </div>
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* split-form                                                          */
/* ------------------------------------------------------------------ */

function SplitForm(p: ViewProps) {
  const { t } = p;
  const id = useId();
  const format = useFormat();
  const [errors, setErrors] = useState<Partial<Record<'name' | 'email' | 'role', string>>>({});
  const [state, setState] = useState<'idle' | 'sending' | 'failed' | 'done'>(p.joined ? 'done' : 'idle');
  const [size, setSize] = useState(0);
  const formRef = useRef<HTMLFormElement>(null);
  const sizes = p.sizes ?? [];
  const submit = async (e: React.FormEvent<HTMLFormElement>) => {
    const f = new FormData(e.currentTarget);
    const data: WaitlistData = { name: String(f.get('name') ?? '').trim(), email: String(f.get('email') ?? '').trim(), role: String(f.get('role') ?? ''), size: sizes[size] };
    const next: typeof errors = {};
    if (!data.name) next.name = t.invalidName;
    if (!EMAIL.test(data.email)) next.email = t.invalidEmail;
    if (p.roles?.length && !data.role) next.role = t.invalidRole;
    setErrors(next);
    const first = Object.keys(next)[0];
    if (first) {
      e.preventDefault();
      (e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus();
      return;
    }
    if (!p.onSubmit && p.formAction) return;
    e.preventDefault();
    setState('sending');
    try {
      await (p.onSubmit ? p.onSubmit(data) : wait(900));
      setState('done');
    } catch {
      setState('failed');
    }
  };
  const err = (k: 'name' | 'email' | 'role') =>
    errors[k] ? { 'aria-invalid': true as const, 'aria-describedby': `${id}-${k}-e` } : {};
  const clear = (k: 'name' | 'email' | 'role') => errors[k] && setErrors((x) => ({ ...x, [k]: undefined }));
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,30rem)] lg:gap-20">
        <div className="lg:pt-4">
          <Head {...p} />
          {p.perks?.length ? (
            <ul className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-1">
              {p.perks.map((perk, i) => (
                <li key={i} className="kit-rise flex gap-4" style={delay(160 + i * 80)}>
                  <span className="grid size-10 shrink-0 place-items-center rounded-xl border border-border bg-card text-primary shadow-[var(--highlight),var(--shadow-sm)]">
                    <Icon name={perk.icon ?? 'check'} size={18} />
                  </span>
                  <span>
                    <span className="block text-[15.5px] font-semibold text-foreground">{perk.title}</span>
                    {perk.text && <span className="mt-1 block text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{perk.text}</span>}
                  </span>
                </li>
              ))}
            </ul>
          ) : null}
        </div>

        <div className="kit-rise relative" style={delay(200)}>
          <div aria-hidden className="pointer-events-none absolute -inset-6 -z-10 rounded-[2rem] bg-[radial-gradient(50%_45%_at_50%_45%,var(--glow),transparent_100%)]" />
          <div className="rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
            {state === 'done' ? (
              <div role="status" className="kit-rise py-6 text-center">
                <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="check" size={26} strokeWidth={2} />
                </span>
                <p className="mt-5 text-[20px] font-semibold text-foreground">{t.youAreIn}</p>
                {p.position !== undefined && (
                  <p className="mt-2 text-[15px] text-muted-foreground">
                    {t.yourPlace} <strong className="font-semibold tabular-nums text-foreground">#{format(p.position)}</strong>
                  </p>
                )}
                {p.nextBatch && <p className="mx-auto mt-5 max-w-[22rem] text-pretty rounded-xl bg-surface px-4 py-3 text-[14px] text-muted-foreground">{fill(t.nextBatch, { date: p.nextBatch })}</p>}
              </div>
            ) : (
              <form ref={formRef} action={p.formAction} method="post" noValidate onSubmit={submit} className="grid gap-5">
                <style>{SHAKE}</style>
                <div>
                  <label htmlFor={`${id}-name`} className="text-sm font-medium text-foreground">
                    {t.name}
                  </label>
                  <input id={`${id}-name`} name="name" autoComplete="name" placeholder={t.namePh} onInput={() => clear('name')} {...err('name')} className={cn(FIELD, 'mt-2', errors.name ? 'border-destructive' : 'border-border')} />
                  {errors.name && <FormError id={`${id}-name-e`}>{errors.name}</FormError>}
                </div>
                <div>
                  <label htmlFor={`${id}-email`} className="text-sm font-medium text-foreground">
                    {t.email}
                  </label>
                  <input id={`${id}-email`} name="email" type="email" inputMode="email" autoComplete="email" placeholder={t.emailPh} onInput={() => clear('email')} {...err('email')} className={cn(FIELD, 'mt-2', errors.email ? 'border-destructive' : 'border-border')} />
                  {errors.email && <FormError id={`${id}-email-e`}>{errors.email}</FormError>}
                </div>
                {p.roles?.length ? (
                  <div>
                    <label htmlFor={`${id}-role`} className="text-sm font-medium text-foreground">
                      {t.role}
                    </label>
                    <span className="relative mt-2 block">
                      <select id={`${id}-role`} name="role" defaultValue="" onChange={() => clear('role')} {...err('role')} className={cn(FIELD, 'cursor-pointer appearance-none pr-10', errors.role ? 'border-destructive' : 'border-border')}>
                        <option value="" disabled>
                          {t.rolePick}
                        </option>
                        {p.roles.map((r) => (
                          <option key={r} value={r}>
                            {r}
                          </option>
                        ))}
                      </select>
                      <Icon name="chevrons-up-down" size={16} className="pointer-events-none absolute right-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
                    </span>
                    {errors.role && <FormError id={`${id}-role-e`}>{errors.role}</FormError>}
                  </div>
                ) : null}
                {sizes.length ? (
                  <fieldset className="min-w-0">
                    <legend className="text-sm font-medium text-foreground">{t.size}</legend>
                    <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
                      {sizes.map((s, i) => (
                        <label key={s} className={cn('relative flex h-11 cursor-pointer items-center justify-center rounded-lg border text-[14px] font-medium transition-[background-color,border-color,color,box-shadow] duration-200 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-primary', size === i ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground shadow-[0_0_0_3px_var(--glow)]' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
                          <input type="radio" name="size" value={s} checked={size === i} onChange={() => setSize(i)} className="sr-only" />
                          {s}
                        </label>
                      ))}
                    </div>
                  </fieldset>
                ) : null}
                <Button type="submit" size="lg" disabled={state === 'sending'} icon={state === 'sending' ? undefined : 'arrow-right'} className="mt-1 w-full">
                  {state === 'sending' && <Spinner />}
                  {state === 'sending' ? t.joining : t.join}
                </Button>
                {state === 'failed' && <FormError id={`${id}-f`}>{t.failed}</FormError>}
                {p.note && (
                  <p className="-mt-1 flex items-center justify-center gap-1.5 text-center text-[13px] text-muted-foreground">
                    <Icon name="lock" size={13} />
                    {p.note}
                  </p>
                )}
              </form>
            )}
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* crowd                                                               */
/* ------------------------------------------------------------------ */

/** Monograms placed on two calm rings around the form, only on wide screens */
const SEATS = [
  [8, 22], [16, 64], [4, 46], [22, 86], [27, 12], [90, 20], [84, 70], [96, 48], [74, 88], [71, 10], [36, 94], [62, 4],
];

function Crowd(p: ViewProps) {
  const { t } = p;
  const people = p.people ?? [];
  const [joined, setJoined] = useState(Boolean(p.joined));
  const [k, setK] = useState(0);
  const [ref, inView] = useInView<HTMLDivElement>();
  useEffect(() => {
    if (people.length < 2 || !inView || prefersReducedMotion()) return;
    const id = window.setInterval(() => setK((x) => (x + 1) % people.length), 3200);
    return () => window.clearInterval(id);
  }, [people.length, inView]);
  const now = people[k];
  return (
    <section className={cn('kit-dots relative overflow-hidden py-24 sm:py-32 lg:py-40', p.className)}>
      <div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(48%_55%_at_50%_45%,var(--glow),transparent_72%)]" />
      <div ref={ref} aria-hidden className="pointer-events-none absolute inset-0 max-lg:hidden">
        {SEATS.slice(0, Math.min(SEATS.length, people.length)).map(([x, y], i) => (
          <Monogram
            key={i}
            name={people[i].name}
            index={i}
            className={cn('absolute size-11 -translate-x-1/2 -translate-y-1/2 text-[12px] shadow-[var(--shadow-md)] transition-[opacity,transform] duration-700 ease-[var(--ease-kit)]', inView ? 'opacity-100' : 'scale-75 opacity-0', 'animate-kit-float motion-reduce:animate-none')}
            style={{ left: `${x}%`, top: `${y}%`, transitionDelay: `${200 + i * 70}ms`, animationDelay: `${-i * 0.7}s`, animationDuration: `${6 + (i % 4)}s`, opacity: inView ? 0.35 + ((i * 37) % 60) / 100 : undefined }}
          />
        ))}
      </div>
      <div className="kit-container relative">
        <div className="mx-auto max-w-[40rem] text-center">
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h1 font-semibold text-foreground">{p.title}</h2>
          {p.description && (
            <p className="kit-rise mx-auto mt-5 max-w-[32rem] text-pretty text-lead text-muted-foreground" style={delay(80)}>
              {p.description}
            </p>
          )}
          <div className="kit-rise mx-auto mt-9 max-w-[30rem] text-left" style={delay(160)}>
            {joined ? (
              <p role="status" className="kit-rise flex items-center justify-center gap-2.5 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] px-4 py-3.5 text-[15px] font-medium text-foreground">
                <Icon name="check-circle" size={19} className="text-success" />
                {t.youAreIn}
              </p>
            ) : (
              <EmailRow t={t} big onDone={() => setJoined(true)} formAction={p.formAction} onSubmit={p.onSubmit} />
            )}
          </div>
          <div className="kit-rise mt-8 flex flex-col items-center gap-4" style={delay(240)}>
            <div className="flex items-center gap-3">
              <span className="flex -space-x-2.5">
                {people.slice(0, 5).map((x, i) => (
                  <Monogram key={i} name={x.name} index={i} />
                ))}
              </span>
              {p.count !== undefined && (
                <p className="text-left text-[14px] leading-tight text-muted-foreground">
                  <span className="block text-[17px] font-semibold tabular-nums text-foreground">
                    <NumberTicker value={p.count + (joined ? 1 : 0)} />
                  </span>
                  <span className="block">{t.waiting}</span>
                </p>
              )}
            </div>
            {now && (
              <p aria-live="off" className="flex h-8 items-center overflow-hidden rounded-full border border-border bg-card px-3 text-[13px] text-muted-foreground shadow-[var(--shadow-sm)]">
                <span key={k} className="flex items-center gap-2 animate-[wl-feed_.6s_var(--ease-kit)] motion-reduce:animate-none">
                  <span className="size-1.5 rounded-full bg-success" aria-hidden />
                  <strong className="font-medium text-foreground">{now.name}</strong>
                  {now.place && (
                    <span className="max-[360px]:hidden">
                      {t.from} {now.place}
                    </span>
                  )}
                  <span>{t.justJoined}</span>
                </span>
              </p>
            )}
            <style>{'@keyframes wl-feed{from{opacity:0;transform:translateY(100%)}}'}</style>
          </div>
          {p.note && <p className="mt-4 text-[13px] text-subtle-foreground">{p.note}</p>}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* invite-code                                                         */
/* ------------------------------------------------------------------ */

function CodeBoxes({ length, value, onChange, invalid, describedBy, label }: { length: number; value: string[]; onChange: (v: string[]) => void; invalid: boolean; describedBy?: string; label: string }) {
  const refs = useRef<(HTMLInputElement | null)[]>([]);
  const set = (i: number, chars: string) => {
    const clean = chars.toUpperCase().replace(/[^A-Z0-9]/g, '');
    if (!clean) return;
    const next = [...value];
    for (let j = 0; j < clean.length && i + j < length; j++) next[i + j] = clean[j];
    onChange(next);
    refs.current[Math.min(length - 1, i + clean.length)]?.focus();
  };
  const half = Math.ceil(length / 2);
  return (
    <div role="group" aria-label={label} className="flex items-center justify-center gap-1 sm:gap-2">
      {Array.from({ length }, (_, i) => (
        <span key={i} className="contents">
          {i === half && <span aria-hidden className="h-px w-2 shrink-0 bg-border-strong sm:mx-1 sm:w-3" />}
          <input
            ref={(el) => {
              refs.current[i] = el;
            }}
            value={value[i] ?? ''}
            inputMode="text"
            autoCapitalize="characters"
            autoComplete={i === 0 ? 'one-time-code' : 'off'}
                        aria-label={`${label} ${i + 1}`}
            aria-invalid={invalid || undefined}
            aria-describedby={describedBy}
            onChange={(e) => {
              const v = e.target.value;
              // typing into a filled box keeps only the new character, a longer value (autofill) spreads over the boxes
              set(i, v.length > 1 && value[i] && v.startsWith(value[i]) ? v.slice(1) : v);
            }}
            onPaste={(e) => {
              e.preventDefault();
              set(i, e.clipboardData.getData('text'));
            }}
            onKeyDown={(e) => {
              if (e.key === 'Backspace' && !value[i] && i > 0) {
                const next = [...value];
                next[i - 1] = '';
                onChange(next);
                refs.current[i - 1]?.focus();
              }
              if (e.key === 'ArrowLeft' && i > 0) refs.current[i - 1]?.focus();
              if (e.key === 'ArrowRight' && i < length - 1) refs.current[i + 1]?.focus();
            }}
            onFocus={(e) => e.currentTarget.select()}
            className={cn(
              'h-12 w-11 min-w-0 rounded-lg border bg-background text-center text-[20px] font-semibold uppercase text-foreground caret-primary transition-[border-color,box-shadow,transform] duration-200 focus-visible:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:h-14 sm:w-12 sm:text-[22px]',
              invalid ? 'border-destructive' : value[i] ? 'border-border-strong' : 'border-border',
            )}
          />
        </span>
      ))}
    </div>
  );
}

function InviteCode(p: ViewProps) {
  const { t } = p;
  const id = useId();
  const length = p.codeLength ?? 6;
  const [tab, setTab] = useState<0 | 1>(1);
  const [code, setCode] = useState<string[]>(Array(length).fill(''));
  const [codeState, setCodeState] = useState<'idle' | 'short' | 'wrong' | 'checking' | 'ok'>('idle');
  const [request, setRequest] = useState<'idle' | 'invalid' | 'sending' | 'failed' | 'done'>('idle');
  const tabs = [t.request, t.redeem];
  const redeem = async (e: React.FormEvent) => {
    e.preventDefault();
    const value = code.join('');
    if (value.length < length) return setCodeState('short');
    setCodeState('checking');
    const ok = p.onRedeem ? await p.onRedeem(value) : (await wait(700), true);
    setCodeState(ok ? 'ok' : 'wrong');
  };
  const ask = async (e: React.FormEvent<HTMLFormElement>) => {
    const f = new FormData(e.currentTarget);
    const email = String(f.get('email') ?? '').trim();
    if (!EMAIL.test(email)) {
      e.preventDefault();
      setRequest('invalid');
      (e.currentTarget.elements.namedItem('email') as HTMLElement | null)?.focus();
      return;
    }
    if (!p.onSubmit && p.formAction) return;
    e.preventDefault();
    setRequest('sending');
    try {
      await (p.onSubmit ? p.onSubmit({ email }) : wait(800));
      setRequest('done');
    } catch {
      setRequest('failed');
    }
  };
  const codeError = codeState === 'short' ? fill(t.codeShort, { n: length }) : codeState === 'wrong' ? t.codeWrong : null;
  return (
    <Section background="dots" className={p.className}>
      <Head {...p} center />
      <div className="kit-rise mx-auto mt-12 max-w-[32rem]" style={delay(160)}>
        <div className="kit-beam relative rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
          <div
            role="tablist"
            aria-label={t.ways}
            className="m-2 grid grid-cols-2 gap-1 rounded-xl bg-surface p-1"
            onKeyDown={(e) => {
              if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
              e.preventDefault();
              const next = tab === 0 ? 1 : 0;
              setTab(next);
              (e.currentTarget.querySelectorAll('[role="tab"]')[next] as HTMLElement | undefined)?.focus();
            }}
          >
            {tabs.map((label, i) => (
              <button
                key={i}
                type="button"
                role="tab"
                id={`${id}-t${i}`}
                aria-selected={tab === i}
                aria-controls={`${id}-p${i}`}
                tabIndex={tab === i ? 0 : -1}
                onClick={() => setTab(i as 0 | 1)}
                className={cn(
                  'h-11 cursor-pointer rounded-lg text-[14px] font-medium transition-[background-color,color,box-shadow] duration-300 ease-[var(--ease-kit)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary',
                  tab === i ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground',
                )}
              >
                {label}
              </button>
            ))}
          </div>

          <div role="tabpanel" id={`${id}-p1`} aria-labelledby={`${id}-t1`} hidden={tab !== 1} className="px-4 pb-7 pt-5 sm:px-8">
            {codeState === 'ok' ? (
              <div role="status" className="kit-rise py-4 text-center">
                <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="unlock" size={24} />
                </span>
                <p className="mt-4 text-[18px] font-semibold text-foreground">{t.codeOk}</p>
                <Button href="#" size="lg" icon="arrow-right" className="mt-6 max-sm:w-full">
                  {t.open}
                </Button>
              </div>
            ) : (
              <form noValidate onSubmit={redeem}>
                <style>{SHAKE}</style>
                <p id={`${id}-cl`} className="text-center text-sm font-medium text-foreground">
                  {t.code}
                </p>
                <div className={cn('mt-4', codeError && 'animate-[wl-shake_.4s_var(--ease-kit)] motion-reduce:animate-none')}>
                  <CodeBoxes
                    length={length}
                    value={code}
                    label={t.code}
                    invalid={Boolean(codeError)}
                    describedBy={`${id}-ch`}
                    onChange={(v) => {
                      setCode(v);
                      if (codeError) setCodeState('idle');
                    }}
                  />
                </div>
                <div id={`${id}-ch`} aria-live="polite" className="mt-3 min-h-10 text-center">
                  {codeError ? (
                    <p className="inline-flex items-start gap-1.5 text-left text-[13px] font-medium leading-5 text-destructive">
                      <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                      {codeError}
                    </p>
                  ) : (
                    <p className="text-[13px] text-muted-foreground">{t.codeHint}</p>
                  )}
                </div>
                <Button type="submit" size="lg" disabled={codeState === 'checking'} icon={codeState === 'checking' ? undefined : 'key'} className="mt-3 w-full">
                  {codeState === 'checking' && <Spinner />}
                  {codeState === 'checking' ? t.checking : t.check}
                </Button>
              </form>
            )}
          </div>

          <div role="tabpanel" id={`${id}-p0`} aria-labelledby={`${id}-t0`} hidden={tab !== 0} className="px-6 pb-7 pt-5 sm:px-8">
            {request === 'done' ? (
              <p role="status" className="kit-rise flex items-start gap-3 rounded-xl bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4 text-[14.5px] text-foreground">
                <Icon name="check-circle" size={19} className="mt-px shrink-0 text-success" />
                {t.sent}
              </p>
            ) : (
              <form action={p.formAction} method="post" noValidate onSubmit={ask} className="grid gap-5">
                <div>
                  <label htmlFor={`${id}-em`} className="text-sm font-medium text-foreground">
                    {t.email}
                  </label>
                  <input
                    id={`${id}-em`}
                    name="email"
                    type="email"
                    inputMode="email"
                    autoComplete="email"
                    placeholder={t.emailPh}
                    aria-invalid={request === 'invalid' || undefined}
                    aria-describedby={request === 'invalid' ? `${id}-em-e` : undefined}
                    onInput={() => request === 'invalid' && setRequest('idle')}
                    className={cn(FIELD, 'mt-2', request === 'invalid' ? 'border-destructive' : 'border-border')}
                  />
                  {request === 'invalid' && <FormError id={`${id}-em-e`}>{t.invalidEmail}</FormError>}
                </div>
                <div>
                  <label htmlFor={`${id}-why`} className="text-sm font-medium text-foreground">
                    {t.why}
                  </label>
                  <textarea id={`${id}-why`} name="why" rows={3} aria-describedby={`${id}-why-h`} className={cn(FIELD, 'mt-2 h-auto resize-none border-border py-3 leading-relaxed')} />
                  <p id={`${id}-why-h`} className="mt-1.5 text-[13px] text-muted-foreground">
                    {t.whyOptional}
                  </p>
                </div>
                <Button type="submit" size="lg" disabled={request === 'sending'} icon={request === 'sending' ? undefined : 'send'} className="w-full">
                  {request === 'sending' && <Spinner />}
                  {request === 'sending' ? t.joining : t.request}
                </Button>
                {request === 'failed' && <FormError id={`${id}-rf`}>{t.failed}</FormError>}
              </form>
            )}
          </div>
        </div>
        {p.note && <p className="mt-5 text-center text-[13px] text-muted-foreground">{p.note}</p>}
      </div>
    </Section>
  );
}

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

const VIEWS: Record<WaitlistVariant, (p: ViewProps) => React.ReactNode> = {
  'referral-queue': ReferralQueue,
  'split-form': SplitForm,
  crowd: Crowd,
  'invite-code': InviteCode,
};

export function Waitlist({ variant = 'referral-queue', labels, ...rest }: WaitlistProps) {
  const t = useTexts(TEXTS, labels);
  const View = VIEWS[variant] ?? ReferralQueue;
  return <View {...rest} variant={variant} t={t} />;
}

Häufige Fragen

Wie installiere ich Warteliste?

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

Welche Varianten hat Warteliste?

4 Varianten, Platz in der Reihe mit Einladungen, Vorteile und kurzes Formular, Menge um ein Feld und Code oder Anfrage. Du wählst sie mit der Prop variant, zum Beispiel variant="split-form".

Kann ich die Texte von Warteliste übersetzen?

Ja. Warteliste bringt 42 eigene Texte auf Deutsch und Englisch mit, etwa „E-Mail-Adresse“, „du@firma.de“ und „Dein Name“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Warteliste einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert