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.
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
Details
Was Warteliste mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Launch und Waitlist
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle WaitlistProps aus components/ui/waitlist.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalWaitlistVariantreferral-queuesplit-formcrowdinvite-code
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodecountoptionalnumberPeople already waiting, counted up in the language format
positionoptionalnumberreferral-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
perksoptionalWaitlistPerk[]split-form: perks with icons
rolesoptionalstring[]split-form: the role picker
sizesoptionalstring[]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
codeLengthoptionalnumberinvite-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.
noteoptionalstringShort reassurance under the form
nextBatchoptionalstringsplit-form: when the next group gets in, e.g. Nov 18
formActionoptionalstringonSubmitoptional(data: WaitlistData) => void | Promise<void>Resolve to show success, throw to show the error
invitesoptionalnumberreferral-queue: invites this person has sent that joined
joinedoptionalbooleanStudio 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| Englisch Email address | Deutsch E-Mail-Adresse | |
| emailPh | Englisch you@company.com | Deutsch du@firma.de |
| name | Englisch Your name | Deutsch Dein Name |
| namePh | Englisch Alex Morgan | Deutsch Alex Morgen |
| role | Englisch What do you do? | Deutsch Was machst du beruflich? |
| rolePick | Englisch Pick your role | Deutsch Rolle wählen |
| size | Englisch Team size | Deutsch Teamgröße |
| join | Englisch Join the waitlist | Deutsch Auf die Warteliste |
| joining | Englisch Saving your place | Deutsch Platz wird gesichert |
| 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. |
| invalidName | Englisch 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. |
| invalidRole | Englisch Please pick the role closest to yours. | Deutsch Bitte wähle die Rolle, die deiner am nächsten kommt. |
| 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. |
| waiting | Englisch people waiting | Deutsch Menschen warten |
| yourPlace | Englisch Your place in line | Deutsch Dein Platz in der Reihe |
| youAreIn | Englisch You are on the list | Deutsch Du stehst auf der Liste |
| share | Englisch Your invite link | Deutsch Dein Einladungslink |
| copy | Englisch Copy link | Deutsch Link kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| moveUp | Englisch Every friend who joins moves you up {n} places. | Deutsch Jede Person, die über dich beitritt, bringt dich {n} Plätze nach vorn. |
| invites | Englisch {n} invites | Deutsch {n} Einladungen |
| invite1 | Englisch 1 invite | Deutsch 1 Einladung |
| unlocked | Englisch Unlocked | Deutsch Freigeschaltet |
| ladder | Englisch Move up the line | Deutsch In der Reihe vorrücken |
| ladderLocked | Englisch Join to get your link and start moving up. | Deutsch Trag dich ein, dann bekommst du deinen Link und rückst vor. |
| nextBatch | Englisch 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. |
| justJoined | Englisch just joined | Deutsch ist gerade dazugekommen |
| from | Englisch from | Deutsch aus |
| request | Englisch Request access | Deutsch Zugang anfragen |
| redeem | Englisch I have a code | Deutsch Ich habe einen Code |
| code | Englisch Invite code | Deutsch Einladungscode |
| codeHint | Englisch The code is in your invite email, letters and digits. | Deutsch Der Code steht in deiner Einladung, Buchstaben und Ziffern. |
| check | Englisch Unlock access | Deutsch Zugang freischalten |
| checking | Englisch Checking the code | Deutsch Code wird geprüft |
| codeShort | Englisch The code has {n} characters. Please fill in every box. | Deutsch Der Code hat {n} Zeichen. Bitte füll jedes Feld aus. |
| codeWrong | Englisch 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. |
| codeOk | Englisch Welcome in. Your account is ready. | Deutsch Willkommen. Dein Konto ist bereit. |
| open | Englisch Open my account | Deutsch Mein Konto öffnen |
| why | Englisch What would you use it for? | Deutsch Wofür würdest du es nutzen? |
| whyOptional | Englisch A sentence helps us invite the right people first. | Deutsch Ein Satz hilft uns, die Richtigen zuerst einzuladen. |
| sent | Englisch Thanks. We read every request and answer within a week. | Deutsch Danke. Wir lesen jede Anfrage und antworten innerhalb einer Woche. |
| ways | Englisch Two ways in | Deutsch 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
'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.