SPB Git

Repository Public

AccessForm.tsx 6.1 KB 109 lines tsx

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
Files main
Menu

Theme