321 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|