Files
sarlinkportal/frontend/src/pages/Register.tsx
T
2026-09-22 00:50:30 +05:00

321 lines
9.6 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, Navigate, useLocation } from "react-router-dom";
import DateField from "@/components/DateField";
import Select from "@/components/Select";
import { Alert, Button, Field, TextInput } from "@/components/ui";
import { api, ApiError } from "@/lib/api";
import { toIsoDate } from "@/lib/date";
import type { Atoll, RegistrationTicket } from "@/lib/api";
const TERMS_URL = "https://sarlink.net/terms";
const POLICY_URL = "https://sarlink.net/policy";
type Errors = Record<string, string>;
/**
* The registration form, reached only after the number was confirmed by SMS:
* `location.state` carries the registration ticket from /login.
*
* Submitting does not create a usable account - it files an application an
* admin has to approve.
*/
export default function Register() {
const location = useLocation();
const ticket = location.state as RegistrationTicket | null;
const [atolls, setAtolls] = useState<Atoll[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [fullName, setFullName] = useState("");
const [idnumber, setIdnumber] = useState("");
const [dateOfBirth, setDateOfBirth] = useState<Date | undefined>(undefined);
const [atollId, setAtollId] = useState("");
const [islandId, setIslandId] = useState("");
const [terms, setTerms] = useState(false);
const [policy, setPolicy] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [formError, setFormError] = useState<string | null>(null);
// The ticket expired or was already used: the number has to be re-verified.
const [needsReverify, setNeedsReverify] = useState(false);
const [busy, setBusy] = useState(false);
const [submitted, setSubmitted] = useState<string | null>(null);
useEffect(() => {
let active = true;
api
.atolls()
.then((result) => {
if (active) setAtolls(result);
})
.catch(() => {
if (active) setLoadError("Couldn't load the island list. Reload to retry.");
});
return () => {
active = false;
};
}, []);
// No ticket means the number was never verified - start over.
if (!ticket?.registration_token && !submitted) {
return <Navigate to="/login" replace />;
}
const islands = atolls?.find((atoll) => `${atoll.id}` === atollId)?.islands ?? [];
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
setFormError(null);
try {
const result = await api.register({
registration_token: ticket!.registration_token,
full_name: fullName,
idnumber: idnumber,
date_of_birth: dateOfBirth ? toIsoDate(dateOfBirth) : "",
atoll: Number(atollId),
island: Number(islandId),
terms_accepted: terms,
policy_accepted: policy,
});
setSubmitted(result.detail);
} catch (err) {
if (err instanceof ApiError) {
const fieldErrors: Errors = {};
for (const [field, messages] of Object.entries(err.errors ?? {})) {
if (Array.isArray(messages)) fieldErrors[field] = messages[0];
}
setErrors(fieldErrors);
const ticketError = fieldErrors.registration_token ?? fieldErrors.mobile;
if (ticketError) {
setNeedsReverify(true);
setFormError(ticketError);
} else {
// Field errors are shown inline; only a general failure needs the banner.
setFormError(Object.keys(fieldErrors).length > 0 ? null : err.message);
}
} else {
setFormError("Something went wrong. Try again.");
}
} finally {
setBusy(false);
}
}
if (submitted) {
return (
<Shell title="Registration received">
<div className="space-y-4 text-center">
<div
aria-hidden
className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-2xl text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">{submitted}</p>
<Link
to="/login"
className="inline-block text-sm text-sky-600 hover:underline dark:text-sky-400"
>
Back to sign in
</Link>
</div>
</Shell>
);
}
return (
<Shell title="Create your account" subtitle="An admin reviews every application.">
{formError ? (
<Alert>
{formError}
{needsReverify ? (
<>
{" "}
<Link to="/login" className="font-medium underline">
Start over
</Link>
</>
) : null}
</Alert>
) : null}
{loadError ? <Alert>{loadError}</Alert> : null}
<form onSubmit={submit} className="space-y-4">
<Field label="Mobile number" hint="Verified by SMS">
<TextInput value={ticket!.mobile} readOnly disabled />
</Field>
<Field label="Full name" error={errors.full_name}>
<TextInput
autoFocus
name="full_name"
autoComplete="name"
value={fullName}
onChange={(event) => setFullName(event.target.value)}
required
/>
</Field>
<Field label="ID Card/Passport/Work Permit Number" error={errors.idnumber}>
<TextInput
name="idnumber"
value={idnumber}
onChange={(event) => setIdnumber(event.target.value)}
required
/>
</Field>
<Field label="Date of birth" error={errors.date_of_birth}>
<DateField value={dateOfBirth} onChange={setDateOfBirth} id="date_of_birth" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Atoll" error={errors.atoll}>
<Select
name="atoll"
value={atollId}
disabled={!atolls}
onChange={(event) => {
setAtollId(event.target.value);
setIslandId("");
}}
required
>
<option value="">{atolls ? "Select" : "Loading…"}</option>
{(atolls ?? []).map((atoll) => (
<option key={atoll.id} value={atoll.id}>
{atoll.name}
</option>
))}
</Select>
</Field>
<Field label="Island" error={errors.island}>
<Select
name="island"
value={islandId}
disabled={!atollId}
onChange={(event) => setIslandId(event.target.value)}
required
>
<option value="">{atollId ? "Select" : "Pick an atoll"}</option>
{islands.map((island) => (
<option key={island.id} value={island.id}>
{island.name}
</option>
))}
</Select>
</Field>
</div>
<div className="space-y-3 rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<Checkbox
name="terms_accepted"
checked={terms}
onChange={setTerms}
error={errors.terms_accepted}
>
I agree to the{" "}
<ExternalLink href={TERMS_URL}>terms and conditions</ExternalLink>.
</Checkbox>
<Checkbox
name="policy_accepted"
checked={policy}
onChange={setPolicy}
error={errors.policy_accepted}
>
I understand the <ExternalLink href={POLICY_URL}>privacy policy</ExternalLink>.
</Checkbox>
</div>
<Button
type="submit"
className="w-full"
disabled={busy || !atolls || needsReverify}
>
{busy ? "Submitting…" : "Register"}
</Button>
</form>
</Shell>
);
}
function Shell({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<main className="min-h-dvh bg-slate-50 px-4 py-10 dark:bg-slate-950">
<div className="mx-auto w-full max-w-md space-y-6">
<header className="space-y-1 text-center">
<h1 className="text-2xl font-semibold text-slate-900 dark:text-white">
{title}
</h1>
{subtitle ? (
<p className="text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>
) : null}
</header>
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
{children}
</div>
</div>
</main>
);
}
function Checkbox({
name,
checked,
onChange,
error,
children,
}: {
name: string;
checked: boolean;
onChange: (value: boolean) => void;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="flex items-start gap-2.5 text-sm text-slate-700 dark:text-slate-200">
<input
type="checkbox"
name={name}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="mt-0.5 size-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500/30 dark:border-slate-600"
/>
<span>{children}</span>
</label>
{error ? (
<p className="mt-1 pl-6.5 text-sm text-rose-600 dark:text-rose-400">{error}</p>
) : null}
</div>
);
}
function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-sky-600 underline hover:no-underline dark:text-sky-400"
>
{children}
</a>
);
}