Repository Public
1"use client";23import { useState, type FormEvent } from "react";4import { getDict, type Locale } from "@/lib/i18n";5import { mailtoHref, requestBody } from "@/lib/mailto";6import { accessRequestSchema } from "@/lib/schemas";78type State = { kind: "idle" } | { kind: "sending" } | { kind: "mail"; body: string; href: string } | { kind: "sent" } | { kind: "error"; message: string };9const REQUIRED = ["name", "email", "projectTitle", "researchQuestion", "compute"] as const;1011/**12 * Access request form. Without a configured mail provider it NEVER claims to have sent anything:13 * it prepares a message in the visitor's own mail application and says so.14 */15export function AccessForm({ locale, mailEnabled, to }: { locale: Locale; mailEnabled: boolean; to: string }) {16 const f = getDict(locale).access.form;17 const [errors, setErrors] = useState<Partial<Record<(typeof REQUIRED)[number], string>>>({});18 const [state, setState] = useState<State>({ kind: "idle" });1920 async function onSubmit(e: FormEvent<HTMLFormElement>) {21 e.preventDefault();22 const raw = Object.fromEntries(new FormData(e.currentTarget).entries());23 const parsed = accessRequestSchema.safeParse({ ...raw, locale });24 if (!parsed.success) {25 const next: typeof errors = {};26 for (const issue of parsed.error.issues) {27 const key = issue.path[0] as (typeof REQUIRED)[number];28 if (REQUIRED.includes(key) && !next[key]) next[key] = f.errors[key];29 }30 setErrors(next);31 setState({ kind: "error", message: f.errors.summary });32 const first = REQUIRED.find((k) => next[k]);33 if (first) e.currentTarget.querySelector<HTMLElement>(`[name="${first}"]`)?.focus();34 return;35 }36 setErrors({});37 const data = parsed.data;38 if (!mailEnabled) {39 const body = requestBody(data, f);40 const href = mailtoHref(to, f.subject(data.projectTitle), body);41 setState({ kind: "mail", body, href });42 window.location.assign(href);43 return;44 }45 setState({ kind: "sending" });46 try {47 const res = await fetch("/api/access", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });48 if (res.ok) setState({ kind: "sent" });49 else setState({ kind: "error", message: res.status === 429 ? f.errors.rate : f.errors.server });50 } catch {51 setState({ kind: "error", message: f.errors.server });52 }53 }5455 const Field = ({ name, label, hint, required = false, type = "text", area = false, autoComplete }: { name: string; label: string; hint?: string; required?: boolean; type?: string; area?: boolean; autoComplete?: string }) => {56 const err = errors[name as (typeof REQUIRED)[number]];57 const describedBy = [hint ? `${name}-hint` : null, err ? `${name}-err` : null].filter(Boolean).join(" ") || undefined;58 const common = { id: name, name, required, "aria-invalid": err ? true : undefined, "aria-describedby": describedBy, className: "field", autoComplete } as const;59 return (60 <div className={area ? "sm:col-span-2" : undefined}>61 <label htmlFor={name} className="mb-2 flex items-baseline justify-between gap-3 text-[0.9375rem] font-medium">62 <span>{label}</span>63 {!required ? <span className="label !normal-case !tracking-normal">{f.optional}</span> : null}64 </label>65 {area ? <textarea {...common} rows={4} /> : <input {...common} type={type} />}66 {hint ? <p id={`${name}-hint`} className="mt-1.5 text-[0.8125rem] text-mute">{hint}</p> : null}67 {err ? <p id={`${name}-err`} className="mt-1.5 text-[0.8125rem] text-warn">{err}</p> : null}68 </div>69 );70 };7172 if (state.kind === "sent") {73 return <p role="status" className="card border-ok/40 p-6 text-[1.0625rem] leading-relaxed" data-testid="access-sent">{f.sent}</p>;74 }7576 return (77 <form noValidate onSubmit={onSubmit} className="grid gap-5 sm:grid-cols-2" data-testid="access-form" data-mode={mailEnabled ? "server" : "mailto"}>78 <p className="rounded-[3px] border border-accent/40 bg-accent/10 p-4 text-[0.9375rem] leading-relaxed text-fg sm:col-span-2" data-testid="access-mode">{mailEnabled ? f.modeServer : f.modeMail}</p>79 {Field({ name: "name", label: f.name, required: true, autoComplete: "name" })}80 {Field({ name: "affiliation", label: f.affiliation, autoComplete: "organization" })}81 {Field({ name: "email", label: f.email, required: true, type: "email", autoComplete: "email" })}82 {Field({ name: "projectTitle", label: f.projectTitle, required: true })}83 {Field({ name: "researchQuestion", label: f.researchQuestion, hint: f.researchQuestionHint, required: true, area: true })}84 {Field({ name: "compute", label: f.compute, hint: f.computeHint, required: true, area: true })}85 {Field({ name: "dates", label: f.dates })}86 {Field({ name: "expectedOutputs", label: f.expectedOutputs })}87 {Field({ name: "dataConstraints", label: f.dataConstraints, hint: f.dataConstraintsHint, area: true })}88 <div className="absolute -left-[9999px] h-px w-px overflow-hidden" aria-hidden="true">89 <label htmlFor="website">{f.website}</label>90 <input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />91 </div>92 <div className="sm:col-span-2">93 <button type="submit" className="btn btn-primary" disabled={state.kind === "sending"}>{state.kind === "sending" ? f.sending : mailEnabled ? f.submitServer : f.submitMail}</button>94 </div>95 <div className="sm:col-span-2" aria-live="polite">96 {state.kind === "error" ? <p role="alert" className="text-[0.9375rem] text-warn" data-testid="access-error">{state.message}</p> : null}97 {state.kind === "mail" ? (98 <div className="card p-5" data-testid="access-mail-ready">99 <p className="text-[0.9375rem] leading-relaxed">{f.mailReady}</p>100 <p className="mt-3 text-[0.875rem] text-body">{f.mailFallback} <a className="link" href={state.href}>{to}</a></p>101 <label htmlFor="mail-body" className="sr-only">{f.title}</label>102 <textarea id="mail-body" readOnly rows={8} className="field mt-3 font-mono text-[0.8125rem]" value={state.body} />103 </div>104 ) : null}105 </div>106 </form>107 );108}109