'use client';
import { useId, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { FileUpload, type UploadItem } from '@/components/ui/file-upload';
import { DateInput } from '@/components/ui/date-input';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ChipGroup, DonePanel, ErrorSummary, FlowHead, isEmail, isPhone, Unfold, useFocusFirstError, type DoneStep, summaryOf, useFormState } from '@/components/ui/form-flow';
/*
* Applying for a job, a shift or a programme. Files are checked on the way
* in, the privacy note says how long data is kept, and every send names what
* is missing and focuses it. Sends everything to `onSubmit`, files as File
* objects in `files`.
*
* job a full application beside the role, CV and letter as files or written
* quick-apply a minute on the phone, days and shifts as chips, CV optional
* guided-letter three short prompts become a letter, the preview builds beside them
* eligibility three yes or no questions first, the form opens when they fit
*/
export type ApplicationFormVariant = 'job' | 'quick-apply' | 'guided-letter' | 'eligibility';
export interface ApplicationRole {
title: string;
team?: string;
location?: string;
type?: string;
salary?: string;
/** Short lines under the title, e.g. what the role does */
points?: string[];
}
export interface ApplicationPrompt {
id: string;
title: string;
hint?: string;
/** Most words for this part */
words?: number;
}
export interface EligibilityQuestion {
id: string;
title: string;
hint?: string;
/** The answer that fits, default yes */
fits?: 'yes' | 'no';
/** What to show when the answer does not fit */
explain?: string;
}
export interface ApplicationFormProps {
variant?: ApplicationFormVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
role?: ApplicationRole;
/** quick-apply: days and shifts to pick */
days?: string[];
shifts?: string[];
/** guided-letter: the prompts */
prompts?: ApplicationPrompt[];
/** eligibility: the questions before the form */
checks?: EligibilityQuestion[];
/** How long applications are kept, shown in the privacy note, e.g. "six months" */
retention?: string;
/** Link to the privacy policy */
privacyHref?: string;
today?: string;
steps?: DoneStep[];
submitLabel?: string;
done?: { title: string; text?: string };
onSubmit?: (data: Record<string, unknown> & { files?: File[] }) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
you: 'About you',
firstName: 'First name',
lastName: 'Last name',
name: 'Name',
nameNeeded: 'Please tell us your name so we know who applies.',
email: 'Email',
emailNeeded: 'This address looks incomplete. Please use a form like name@example.com so our answer reaches you.',
phone: 'Phone',
phoneNeeded: 'Please add a number with area code, we call back within two days.',
documents: 'Documents',
cv: 'CV',
cvHint: 'PDF up to 10 MB',
cvNeeded: 'Please add your CV as a PDF, it is the one document we need.',
letter: 'Cover letter',
letterUpload: 'Upload a file',
letterWrite: 'Write it here',
letterPlaceholder: 'A few lines on why this role and why you',
optional: 'optional',
more: 'A few more things',
start: 'Earliest start',
salary: 'Salary expectation per year',
links: 'Portfolio or profile',
linksHint: 'A link to your work, if you have one',
privacy: 'I agree that my application is stored and read for this role.',
privacyHint: 'We keep it for {r} and delete it after that, unless you allow more.',
privacyLink: 'Privacy policy',
retention: 'six months',
privacyNeeded: 'Please agree so we are allowed to read your application.',
pool: 'Keep me in mind for other roles for one year',
send: 'Send application',
role: 'The role',
days: 'Which days suit you?',
daysNeeded: 'Please choose at least one day, so we can plan you in.',
shifts: 'Which shifts?',
shiftsNeeded: 'Please choose at least one shift.',
cvOptional: 'CV, if you have one',
quickNote: 'Takes about a minute. We call you back within two days.',
words: '{n} of {max} words',
tooLong: '{n} words over, please shorten it a little.',
promptNeeded: 'Please write a few words here, it is part of the letter.',
preview: 'Your letter',
previewEmpty: 'Your answers become a letter here as you write.',
greeting: 'Dear hiring team,',
closing: 'Kind regards',
check: 'Before you start',
checkText: 'Three questions to see if this programme fits you.',
yes: 'Yes',
no: 'No',
fitsTitle: 'You fit, welcome to apply',
fitsText: 'The application opens below.',
notYetTitle: 'This round is not the right one yet',
notYetText: 'Change an answer if you chose by mistake.',
motivation: 'Why do you want to take part?',
motivationNeeded: 'Please tell us in a few sentences why, it is what the jury reads first.',
doneTitle: 'Thank you, your application has arrived',
doneText: 'You get a confirmation by email. We answer every application within two weeks.',
again: 'Back to the start',
},
de: {
you: 'Zu dir',
firstName: 'Vorname',
lastName: 'Nachname',
name: 'Name',
nameNeeded: 'Bitte nenne uns deinen Namen, damit wir wissen, wer sich bewirbt.',
email: 'E-Mail',
emailNeeded: 'Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit unsere Antwort dich erreicht.',
phone: 'Telefon',
phoneNeeded: 'Bitte gib eine Nummer mit Vorwahl an, wir rufen innerhalb von zwei Tagen zurück.',
documents: 'Unterlagen',
cv: 'Lebenslauf',
cvHint: 'PDF bis 10 MB',
cvNeeded: 'Bitte füge deinen Lebenslauf als PDF hinzu, nur diese Unterlage brauchen wir.',
letter: 'Anschreiben',
letterUpload: 'Datei hochladen',
letterWrite: 'Hier schreiben',
letterPlaceholder: 'Ein paar Zeilen, warum diese Stelle und warum du',
optional: 'optional',
more: 'Noch ein paar Dinge',
start: 'Frühester Start',
salary: 'Gehaltswunsch pro Jahr',
links: 'Portfolio oder Profil',
linksHint: 'Ein Link zu deiner Arbeit, falls vorhanden',
privacy: 'Ich bin einverstanden, dass meine Bewerbung für diese Stelle gespeichert und gelesen wird.',
privacyHint: 'Wir bewahren sie {r} auf und löschen sie danach, außer du erlaubst mehr.',
privacyLink: 'Datenschutzerklärung',
retention: 'sechs Monate',
privacyNeeded: 'Bitte stimme zu, damit wir deine Bewerbung lesen dürfen.',
pool: 'Für andere Stellen ein Jahr lang an mich denken',
send: 'Bewerbung senden',
role: 'Die Stelle',
days: 'Welche Tage passen dir?',
daysNeeded: 'Bitte wähle mindestens einen Tag, damit wir dich einplanen können.',
shifts: 'Welche Schichten?',
shiftsNeeded: 'Bitte wähle mindestens eine Schicht.',
cvOptional: 'Lebenslauf, falls vorhanden',
quickNote: 'Dauert etwa eine Minute. Wir rufen innerhalb von zwei Tagen zurück.',
words: '{n} von {max} Wörtern',
tooLong: '{n} Wörter zu viel, bitte kürze ein wenig.',
promptNeeded: 'Bitte schreib hier ein paar Worte, sie gehören zum Anschreiben.',
preview: 'Dein Anschreiben',
previewEmpty: 'Aus deinen Antworten entsteht hier beim Schreiben ein Anschreiben.',
greeting: 'Liebes Team,',
closing: 'Viele Grüße',
check: 'Bevor du startest',
checkText: 'Drei Fragen, ob das Programm zu dir passt.',
yes: 'Ja',
no: 'Nein',
fitsTitle: 'Du passt, bewirb dich gern',
fitsText: 'Die Bewerbung öffnet sich darunter.',
notYetTitle: 'Diese Runde passt noch nicht',
notYetText: 'Ändere eine Antwort, falls du dich verklickt hast.',
motivation: 'Warum möchtest du teilnehmen?',
motivationNeeded: 'Bitte schreib in ein paar Sätzen, warum, das liest die Jury zuerst.',
doneTitle: 'Danke, deine Bewerbung ist angekommen',
doneText: 'Du bekommst eine Bestätigung per E-Mail. Wir antworten auf jede Bewerbung innerhalb von zwei Wochen.',
again: 'Zurück zum Anfang',
},
};
type Texts = (typeof TEXTS)['en'];
/** A label with the required star in the kit colour */
function Req({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
*
</span>
</>
);
}
const asItems = (files: File[], prefix: string): UploadItem[] => files.map((f, i) => ({ id: `${prefix}-${i}-${f.name}`, name: f.name, size: f.size, status: 'done' }));
function Done({ p, t, onAgain, className }: { p: ApplicationFormProps; t: Texts; onAgain: () => void; className?: string }) {
return (
<div className={cn('kit-card mx-auto p-6 sm:p-10', className)}>
<DonePanel
title={p.done?.title ?? t.doneTitle}
text={p.done?.text ?? t.doneText}
steps={p.steps}
actions={
<Button variant="outline" iconLeft="rotate-ccw" onClick={onAgain}>
{t.again}
</Button>
}
/>
</div>
);
}
function Privacy({ id, p, t, f, value, set }: { id: string; p: ApplicationFormProps; t: Texts; f: ReturnType<typeof useFormState>; value: boolean; set: (v: boolean) => void }) {
return (
<div>
<Checkbox
id={`${id}-privacy`}
label={t.privacy}
description={
<>
{t.privacyHint.replace('{r}', p.retention ?? t.retention)}{' '}
{p.privacyHref && (
<a href={p.privacyHref} className="font-medium text-foreground underline underline-offset-4 hover:text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{t.privacyLink}
</a>
)}
</>
}
checked={value}
onChange={(e) => (set(e.target.checked), f.clear('privacy'))}
error={f.errors.privacy}
/>
</div>
);
}
function RoleCard({ role, t }: { role: ApplicationRole; t: Texts }) {
const facts: [IconName, string | undefined][] = [
['map-pin', role.location],
['clock', role.type],
['users', role.team],
['coins', role.salary],
];
return (
<div className="kit-card overflow-hidden">
<div className="border-b border-border bg-surface px-6 py-5">
<p className="text-[12.5px] font-medium text-muted-foreground">{t.role}</p>
<p className="mt-1 text-[19px] font-semibold leading-snug tracking-[-0.02em] text-foreground">{role.title}</p>
</div>
<ul className="grid gap-3 px-6 py-5">
{facts
.filter(([, v]) => v)
.map(([icon, v]) => (
<li key={icon} className="flex items-center gap-3 text-[14px] text-foreground">
<Icon name={icon} size={16} className="shrink-0 text-muted-foreground" />
{v}
</li>
))}
</ul>
{role.points && role.points.length > 0 && (
<Stagger step={80} as="ul" className="grid gap-2.5 border-t border-border px-6 py-5">
{role.points.map((x) => (
<li key={x} className="flex items-start gap-2.5 text-[13.5px] leading-snug text-muted-foreground">
<Icon name="check" size={14} className="mt-0.5 shrink-0 text-success" />
{x}
</li>
))}
</Stagger>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* job */
/* ------------------------------------------------------------------ */
function Job({ p, t }: { p: ApplicationFormProps; t: Texts }) {
const id = useId();
const f = useFormState();
const [v, setV] = useState({ first: '', last: '', email: '', phone: '', salary: '', link: '', letter: '' });
const [start, setStart] = useState<string | null>(null);
const [cv, setCv] = useState<File[]>([]);
const [letterFile, setLetterFile] = useState<File[]>([]);
const [letterMode, setLetterMode] = useState<'upload' | 'write'>('write');
const [privacy, setPrivacy] = useState(false);
const [pool, setPool] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const set = (k: keyof typeof v) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => (setV((x) => ({ ...x, [k]: e.target.value })), f.clear(k));
const summary = summaryOf(f.errors, [
['first', `${id}-first`, t.firstName],
['email', `${id}-email`, t.email],
['cv', `${id}-cv`, t.cv],
['privacy', `${id}-privacy`, t.privacyLink],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setCv([]), setPrivacy(false), f.setDone(false))} className="max-w-[48rem]" />;
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[19rem_minmax(0,1fr)] lg:gap-10">
{p.role && (
<Reveal effect="rise" className="lg:sticky lg:top-24 lg:self-start">
<RoleCard role={p.role} t={t} />
</Reveal>
)}
<Reveal effect="rise" delay={80} className="min-w-0">
<form
ref={formRef}
noValidate
className="kit-card grid gap-8 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!v.first.trim()) found.first = t.nameNeeded;
if (!isEmail(v.email)) found.email = t.emailNeeded;
if (!cv.length) found.cv = t.cvNeeded;
if (!privacy) found.privacy = t.privacyNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ ...v, start, pool, files: [...cv, ...letterFile] });
f.setDone(true);
}}
>
<ErrorSummary ref={summaryRef} errors={summary} />
<fieldset className="grid min-w-0 grid-cols-1 gap-5 sm:grid-cols-2">
<legend className="mb-4 text-[15px] font-semibold text-foreground">{t.you}</legend>
<Input id={`${id}-first`} label={t.firstName} required autoComplete="given-name" value={v.first} onChange={set('first')} error={f.errors.first} />
<Input label={t.lastName} autoComplete="family-name" value={v.last} onChange={set('last')} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={set('email')} error={f.errors.email} />
<Input type="tel" inputMode="tel" label={t.phone} optional autoComplete="tel" value={v.phone} onChange={set('phone')} />
</fieldset>
<div className="border-t border-border pt-7"><fieldset className="grid min-w-0 gap-5">
<legend className="mb-4 text-[15px] font-semibold text-foreground">{t.documents}</legend>
<div id={`${id}-cv`}>
<FileUpload label={<Req>{t.cv}</Req>} hint={t.cvHint} accept=".pdf" maxSize={10e6} maxFiles={1} multiple={false} items={asItems(cv, 'cv')} onFiles={(x) => (setCv(x.slice(0, 1)), f.clear('cv'))} onRemove={() => setCv([])} error={f.errors.cv} />
</div>
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-medium text-foreground">
{t.letter} <span className="text-xs font-normal text-subtle-foreground">{t.optional}</span>
</p>
{/* Upload or write, a small switch with a sliding tile */}
<div role="radiogroup" aria-label={t.letter} className="relative grid grid-cols-2 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-0.5">
<span aria-hidden className={cn('absolute inset-y-0.5 start-0.5 w-[calc(50%-0.125rem)] rounded-md bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none', letterMode === 'upload' && 'translate-x-full rtl:-translate-x-full')} />
{(['write', 'upload'] as const).map((m) => (
<label key={m} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center rounded-md px-3 text-[13px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] sm:min-h-9">
<input type="radio" name={`${id}-lm`} checked={letterMode === m} onChange={() => setLetterMode(m)} className="sr-only" />
{m === 'write' ? t.letterWrite : t.letterUpload}
</label>
))}
</div>
</div>
<div key={letterMode} className="kit-rise mt-3">
{letterMode === 'write' ? (
<Textarea aria-label={t.letter} placeholder={t.letterPlaceholder} value={v.letter} onChange={set('letter')} minRows={5} maxLength={3000} />
) : (
<FileUpload variant="compact" accept=".pdf,.doc,.docx" maxSize={10e6} maxFiles={1} multiple={false} items={asItems(letterFile, 'letter')} onFiles={(x) => setLetterFile(x.slice(0, 1))} onRemove={() => setLetterFile([])} />
)}
</div>
</div>
</fieldset></div>
<div className="border-t border-border pt-7"><fieldset className="grid min-w-0 grid-cols-1 gap-5 sm:grid-cols-2">
<legend className="mb-4 text-[15px] font-semibold text-foreground">{t.more}</legend>
<DateInput label={t.start} optional today={p.today} min={p.today} value={start} onValueChange={setStart} />
<Input label={t.salary} optional inputMode="numeric" value={v.salary} onChange={set('salary')} />
<Input type="url" label={t.links} optional hint={t.linksHint} placeholder="https://" value={v.link} onChange={set('link')} className="sm:col-span-2" />
</fieldset></div>
<div className="grid gap-4 border-t border-border pt-7">
<Privacy id={id} p={p} t={t} f={f} value={privacy} set={setPrivacy} />
<Checkbox label={t.pool} checked={pool} onChange={(e) => setPool(e.target.checked)} />
</div>
<div className="flex justify-end">
<Button type="submit" icon="send" magnetic className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
</div>
);
}
/* ------------------------------------------------------------------ */
/* quick-apply */
/* ------------------------------------------------------------------ */
function QuickApply({ p, t }: { p: ApplicationFormProps; t: Texts }) {
const id = useId();
const f = useFormState();
const [v, setV] = useState({ name: '', phone: '' });
const [days, setDays] = useState<string[]>([]);
const [shifts, setShifts] = useState<string[]>([]);
const [cv, setCv] = useState<File[]>([]);
const [privacy, setPrivacy] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const summary = summaryOf(f.errors, [
['name', `${id}-name`, t.name],
['phone', `${id}-phone`, t.phone],
['days', `${id}-days`, t.days],
['shifts', `${id}-shifts`, t.shifts],
['privacy', `${id}-privacy`, t.privacyLink],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setDays([]), setShifts([]), setPrivacy(false), f.setDone(false))} className="max-w-[40rem]" />;
return (
<Reveal effect="scale" className="mx-auto max-w-[40rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!v.name.trim()) found.name = t.nameNeeded;
if (!isPhone(v.phone)) found.phone = t.phoneNeeded;
if (!days.length) found.days = t.daysNeeded;
if (!shifts.length) found.shifts = t.shiftsNeeded;
if (!privacy) found.privacy = t.privacyNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ ...v, days, shifts, files: cv });
f.setDone(true);
}}
>
{p.role && (
<div className="flex items-center gap-4 border-b border-border bg-surface px-6 py-4 sm:px-8">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<Icon name="briefcase" size={20} />
</span>
<span className="min-w-0">
<span className="block truncate text-[16px] font-semibold text-foreground">{p.role.title}</span>
<span className="block text-[13px] leading-snug text-muted-foreground">{[p.role.location, p.role.type, p.role.salary].filter(Boolean).join(' · ')}</span>
</span>
</div>
)}
<div className="grid gap-6 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" size="lg" value={v.name} onChange={(e) => (setV((x) => ({ ...x, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
<Input id={`${id}-phone`} type="tel" inputMode="tel" label={t.phone} required autoComplete="tel" size="lg" value={v.phone} onChange={(e) => (setV((x) => ({ ...x, phone: e.target.value })), f.clear('phone'))} error={f.errors.phone} />
</div>
<ChipGroup id={`${id}-days`} legend={t.days} required multiple options={(p.days ?? []).map((d) => ({ value: d, label: d }))} value={days} onChange={(x) => (setDays(x), f.clear('days'))} error={f.errors.days} layout={(p.days?.length ?? 0) === 7 ? 7 : 'wrap'} />
<ChipGroup id={`${id}-shifts`} legend={t.shifts} required multiple options={(p.shifts ?? []).map((s) => ({ value: s, label: s, icon: 'clock' as IconName }))} value={shifts} onChange={(x) => (setShifts(x), f.clear('shifts'))} error={f.errors.shifts} layout={(p.shifts?.length ?? 0) === 3 ? 3 : 'wrap'} />
<FileUpload variant="compact" label={t.cvOptional} hint={t.cvHint} accept=".pdf,image/*" maxSize={10e6} maxFiles={1} multiple={false} items={asItems(cv, 'cv')} onFiles={(x) => setCv(x.slice(0, 1))} onRemove={() => setCv([])} />
<Privacy id={id} p={p} t={t} f={f} value={privacy} set={setPrivacy} />
<Button type="submit" size="lg" icon="send" className="w-full">
{p.submitLabel ?? t.send}
</Button>
<p className="-mt-2 text-center text-[13px] text-muted-foreground">{t.quickNote}</p>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* guided-letter */
/* ------------------------------------------------------------------ */
const countWords = (s: string) => (s.trim() ? s.trim().split(/\s+/).length : 0);
function GuidedLetter({ p, t }: { p: ApplicationFormProps; t: Texts }) {
const id = useId();
const locale = useLocale();
const f = useFormState();
const prompts = p.prompts ?? [];
const [answers, setAnswers] = useState<Record<string, string>>({});
const [who, setWho] = useState({ name: '', email: '' });
const [cv, setCv] = useState<File[]>([]);
const [privacy, setPrivacy] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const parts = prompts.map((q) => answers[q.id]?.trim()).filter(Boolean) as string[];
const summary = summaryOf(f.errors, [
...prompts.map((q) => [q.id, `${id}-${q.id}`, q.title] as [string, string, string]),
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
['cv', `${id}-cv`, t.cv],
['privacy', `${id}-privacy`, t.privacyLink],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setAnswers({}), setPrivacy(false), f.setDone(false))} className="max-w-[48rem]" />;
return (
<form
ref={formRef}
noValidate
className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)] lg:gap-10"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
for (const q of prompts) {
const n = countWords(answers[q.id] ?? '');
if (!n) found[q.id] = t.promptNeeded;
else if (q.words && n > q.words) found[q.id] = t.tooLong.replace('{n}', String(n - q.words));
}
if (!who.name.trim()) found.name = t.nameNeeded;
if (!isEmail(who.email)) found.email = t.emailNeeded;
if (!cv.length) found.cv = t.cvNeeded;
if (!privacy) found.privacy = t.privacyNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ answers, ...who, files: cv });
f.setDone(true);
}}
>
<div className="grid min-w-0 gap-5">
<ErrorSummary ref={summaryRef} errors={summary} />
<Stagger step={100} as="ol" className="grid gap-4">
{prompts.map((q, i) => {
const n = countWords(answers[q.id] ?? '');
const over = q.words !== undefined && n > q.words;
return (
<li key={q.id} className="kit-card p-5 sm:p-6">
<div className="flex items-baseline gap-3">
<span aria-hidden className="text-[13px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<Textarea
id={`${id}-${q.id}`}
label={q.title}
required
hint={f.errors[q.id] ? undefined : q.hint}
error={f.errors[q.id]}
value={answers[q.id] ?? ''}
onChange={(e) => (setAnswers((a) => ({ ...a, [q.id]: e.target.value })), f.clear(q.id))}
minRows={3}
invalid={over}
footer={
q.words ? (
<span className={cn('text-xs tabular-nums transition-colors', over ? 'font-medium text-destructive' : n > q.words * 0.85 ? 'text-foreground' : 'text-subtle-foreground')}>
{t.words.replace('{n}', n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')).replace('{max}', String(q.words))}
</span>
) : undefined
}
/>
</div>
</div>
</li>
);
})}
</Stagger>
<div className="kit-card grid grid-cols-1 gap-5 p-5 sm:grid-cols-2 sm:p-6">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={who.name} onChange={(e) => (setWho((w) => ({ ...w, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={who.email} onChange={(e) => (setWho((w) => ({ ...w, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
<div id={`${id}-cv`} className="sm:col-span-2">
<FileUpload variant="compact" label={<Req>{t.cv}</Req>} hint={t.cvHint} accept=".pdf" maxSize={10e6} maxFiles={1} multiple={false} items={asItems(cv, 'cv')} onFiles={(x) => (setCv(x.slice(0, 1)), f.clear('cv'))} onRemove={() => setCv([])} error={f.errors.cv} />
</div>
<div className="sm:col-span-2">
<Privacy id={id} p={p} t={t} f={f} value={privacy} set={setPrivacy} />
</div>
</div>
</div>
{/* The letter builds itself from the answers, paper-like and calm */}
<div className="min-w-0 lg:sticky lg:top-24 lg:self-start">
<Reveal effect="rise" delay={120}>
<div className="rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-8">
<p className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<Icon name="file" size={15} />
{t.preview}
</p>
<div className="mt-5 min-h-[9rem] text-pretty lg:min-h-[14rem] text-[14.5px] leading-[1.7] text-foreground">
{parts.length ? (
<>
<p>{t.greeting}</p>
{parts.map((x, i) => (
<p key={i} className="kit-rise mt-3 whitespace-pre-line">
{x}
</p>
))}
<p className="mt-5">{t.closing}</p>
<p className="font-medium">{who.name || '…'}</p>
</>
) : (
<div className="grid gap-2.5">
<p className="text-muted-foreground">{t.previewEmpty}</p>
{[92, 100, 84, 96, 60].map((w, i) => (
<span key={i} aria-hidden className="h-2.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]" style={{ width: `${w}%` }} />
))}
</div>
)}
</div>
<Button type="submit" icon="send" className="mt-7 w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</Reveal>
</div>
</form>
);
}
/* ------------------------------------------------------------------ */
/* eligibility */
/* ------------------------------------------------------------------ */
function Eligibility({ p, t }: { p: ApplicationFormProps; t: Texts }) {
const id = useId();
const f = useFormState();
const checks = p.checks ?? [];
const [answers, setAnswers] = useState<Record<string, 'yes' | 'no'>>({});
const [v, setV] = useState({ name: '', email: '', motivation: '' });
const [files, setFiles] = useState<File[]>([]);
const [privacy, setPrivacy] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const allAnswered = checks.every((c) => answers[c.id]);
const misses = checks.filter((c) => answers[c.id] && answers[c.id] !== (c.fits ?? 'yes'));
const fits = allAnswered && misses.length === 0;
const summary = summaryOf(f.errors, [
['name', `${id}-name`, t.name],
['email', `${id}-email`, t.email],
['motivation', `${id}-motivation`, t.motivation],
['privacy', `${id}-privacy`, t.privacyLink],
]);
if (f.done) return <Done p={p} t={t} onAgain={() => (setAnswers({}), setPrivacy(false), f.setDone(false))} className="max-w-[46rem]" />;
return (
<div className="mx-auto max-w-[46rem]">
<Reveal effect="rise" className="kit-card p-6 sm:p-8">
<p className="text-[17px] font-semibold text-foreground">{t.check}</p>
<p className="mt-1 text-[14px] text-muted-foreground">{t.checkText}</p>
<Stagger step={90} as="ol" className="mt-6 grid gap-0 border-t border-border">
{checks.map((c, i) => {
const a = answers[c.id];
const miss = a && a !== (c.fits ?? 'yes');
return (
<li key={c.id} className="border-b border-border py-4">
<fieldset className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<legend className="sr-only">{c.title}</legend>
<div aria-hidden className="min-w-0">
<p className="text-[15px] font-medium leading-snug text-foreground">
<span className="me-2 tabular-nums text-subtle-foreground">{i + 1}</span>
{c.title}
</p>
{c.hint && <p className="mt-0.5 text-[13px] text-muted-foreground">{c.hint}</p>}
</div>
<div className="flex shrink-0 gap-2">
{(['yes', 'no'] as const).map((x) => (
<label key={x} className={cn('flex min-h-11 min-w-[5.5rem] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border px-4 text-[14px] font-medium transition-[background-color,border-color,color] duration-200 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] sm:flex-none', a === x ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border bg-card text-foreground hover:border-border-strong')}>
<input type="radio" name={`${id}-${c.id}`} checked={a === x} onChange={() => setAnswers((s) => ({ ...s, [c.id]: x }))} className="sr-only" />
{a === x && <Icon name="check" size={15} strokeWidth={2.2} className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />}
{x === 'yes' ? t.yes : t.no}
</label>
))}
</div>
</fieldset>
{miss && c.explain && <p className="kit-rise mt-3 rounded-lg bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] px-3.5 py-2.5 text-[13.5px] leading-snug text-warning-foreground">{c.explain}</p>}
</li>
);
})}
</Stagger>
{allAnswered && (
<p role="status" className={cn('kit-rise mt-5 flex items-start gap-3 rounded-xl border p-4', fits ? 'border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))]' : 'border-border bg-surface')}>
<Icon name={fits ? 'check-circle' : 'info'} size={20} className={cn('mt-0.5 shrink-0', fits ? 'text-success' : 'text-muted-foreground')} />
<span>
<span className="block text-[15px] font-semibold text-foreground">{fits ? t.fitsTitle : t.notYetTitle}</span>
<span className="mt-0.5 block text-[13.5px] text-muted-foreground">{fits ? t.fitsText : t.notYetText}</span>
</span>
</p>
)}
</Reveal>
{/* The application opens with a soft height once every answer fits */}
<Unfold open={fits}>
<form
ref={formRef}
noValidate
className="kit-card mt-5 grid gap-6 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!v.name.trim()) found.name = t.nameNeeded;
if (!isEmail(v.email)) found.email = t.emailNeeded;
if (!v.motivation.trim()) found.motivation = t.motivationNeeded;
if (!privacy) found.privacy = t.privacyNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ answers, ...v, files });
f.setDone(true);
}}
>
<ErrorSummary ref={summaryRef} errors={summary} />
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={(e) => (setV((x) => ({ ...x, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={(e) => (setV((x) => ({ ...x, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
</div>
<Textarea id={`${id}-motivation`} label={t.motivation} required value={v.motivation} onChange={(e) => (setV((x) => ({ ...x, motivation: e.target.value })), f.clear('motivation'))} error={f.errors.motivation} minRows={5} maxLength={2000} />
<FileUpload label={t.documents} hint={t.cvHint} accept=".pdf" maxSize={10e6} maxFiles={3} items={asItems(files, 'doc')} onFiles={(x) => setFiles((y) => [...y, ...x].slice(0, 3))} onRemove={(fid) => setFiles((y) => y.filter((_, i) => `doc-${i}-${y[i].name}` !== fid))} />
<Privacy id={id} p={p} t={t} f={f} value={privacy} set={setPrivacy} />
<div className="flex justify-end">
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Unfold>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* Applications for roles, shifts and programmes.
* <ApplicationForm variant="job" role={{ title, location, type, points }} retention="six months" privacyHref="/privacy" />
*/
export function ApplicationForm(props: ApplicationFormProps) {
const { variant = 'job', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const center = variant !== 'job';
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align={center ? 'center' : 'left'} className="mb-10 sm:mb-14" />
{variant === 'job' && <Job p={props} t={t} />}
{variant === 'quick-apply' && <QuickApply p={props} t={t} />}
{variant === 'guided-letter' && <GuidedLetter p={props} t={t} />}
{variant === 'eligibility' && <Eligibility p={props} t={t} />}
</Section>
);
}