259 lines
7.8 KiB
TypeScript
259 lines
7.8 KiB
TypeScript
"use client";
|
||
import { useI18n } from "@/components/I18nProvider";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react";
|
||
import { api } from "./StartGame";
|
||
import { authActionSchema, authDestination } from "@/shared/account";
|
||
|
||
type Mode = "login" | "signup" | "recover";
|
||
export function SignIn({
|
||
next = "/dashboard",
|
||
initialError = "",
|
||
}: {
|
||
next?: string;
|
||
initialError?: string;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const [mode, setMode] = useState<Mode>("login"),
|
||
[email, setEmail] = useState(""),
|
||
[name, setName] = useState(""),
|
||
[password, setPassword] = useState(""),
|
||
[confirmation, setConfirmation] = useState(""),
|
||
[message, setMessage] = useState(""),
|
||
[error, setError] = useState(initialError),
|
||
[busy, setBusy] = useState(false);
|
||
const router = useRouter();
|
||
function changeMode(value: Mode) {
|
||
setMode(value);
|
||
setError("");
|
||
setMessage("");
|
||
setPassword("");
|
||
setConfirmation("");
|
||
}
|
||
async function submit(event: React.FormEvent) {
|
||
event.preventDefault();
|
||
setError("");
|
||
setMessage("");
|
||
const body =
|
||
mode === "signup"
|
||
? { action: mode, email, password, displayName: name, next }
|
||
: mode === "login"
|
||
? { action: mode, email, password, next }
|
||
: { action: mode, email };
|
||
const valid = authActionSchema.safeParse(body);
|
||
if (!valid.success) {
|
||
setError(valid.error.issues[0].message);
|
||
return;
|
||
}
|
||
if (mode === "signup" && password !== confirmation) {
|
||
setError("Your passwords do not match.");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
const result = await api<{ next?: string; message?: string }>(
|
||
"auth",
|
||
valid.data,
|
||
);
|
||
setPassword("");
|
||
setConfirmation("");
|
||
if (result.next) {
|
||
router.replace(authDestination(result.next));
|
||
router.refresh();
|
||
} else setMessage(result.message ?? "Check your email to continue.");
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
const title = {
|
||
login: "Welcome back.",
|
||
signup: "Make yourself at home.",
|
||
recover: "Find your way back.",
|
||
}[mode];
|
||
return (
|
||
<div className="auth-layout">
|
||
<aside className="auth-story">
|
||
<span className="eyebrow">{t("YOUR NEXT EXPEDITION")}</span>
|
||
<h1>
|
||
{t("A familiar world.")}
|
||
<br />
|
||
{t("Your own perspective.")}
|
||
</h1>
|
||
<p>
|
||
{t(
|
||
"Keep your journeys, share the places you know, and help build the next map.",
|
||
)}
|
||
</p>
|
||
<div className="auth-story-map" aria-hidden="true">
|
||
<Compass size={52} />
|
||
<span>{t("Every world has a story.")}</span>
|
||
</div>
|
||
<div className="auth-benefits">
|
||
<span>
|
||
<Compass size={18} />
|
||
{t("Save your expeditions")}
|
||
</span>
|
||
<span>
|
||
<ShieldCheck size={18} />
|
||
{t("Keep control of your maps")}
|
||
</span>
|
||
</div>
|
||
</aside>
|
||
<section className="auth-card">
|
||
<div className="auth-tabs" aria-label={t("Account options")}>
|
||
<button
|
||
type="button"
|
||
aria-pressed={mode === "login"}
|
||
disabled={busy}
|
||
onClick={() => changeMode("login")}
|
||
>
|
||
{t("Log in")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-pressed={mode === "signup"}
|
||
disabled={busy}
|
||
onClick={() => changeMode("signup")}
|
||
>
|
||
{t("Create account")}
|
||
</button>
|
||
</div>
|
||
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
|
||
<h2>{t(title)}</h2>
|
||
<p className="muted">
|
||
{mode === "signup"
|
||
? t("Choose the name people will see on your maps.")
|
||
: mode === "recover"
|
||
? t("We’ll email you a link to set a new password.")
|
||
: t("Log in with your email and password.")}
|
||
</p>
|
||
<form onSubmit={submit} aria-busy={busy}>
|
||
{mode === "signup" && (
|
||
<label className="field">
|
||
{t("Display name")}
|
||
<input
|
||
name="displayName"
|
||
autoComplete="nickname"
|
||
minLength={2}
|
||
maxLength={32}
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder={t("How should we call you?")}
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
<small>
|
||
{t("2–32 characters. You can change it in settings.")}
|
||
</small>
|
||
</label>
|
||
)}
|
||
<label className="field">
|
||
{t("Email address")}
|
||
<input
|
||
name="email"
|
||
type="email"
|
||
autoComplete="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
placeholder="you@example.com"
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
{(mode === "login" || mode === "signup") && (
|
||
<label className="field">
|
||
{t("Password")}
|
||
<input
|
||
name="password"
|
||
type="password"
|
||
autoComplete={
|
||
mode === "signup" ? "new-password" : "current-password"
|
||
}
|
||
minLength={mode === "signup" ? 12 : 1}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
{mode === "signup" && (
|
||
<small>
|
||
{t(
|
||
"Use at least 12 characters. A unique passphrase works well.",
|
||
)}
|
||
</small>
|
||
)}
|
||
</label>
|
||
)}
|
||
{mode === "signup" && (
|
||
<label className="field">
|
||
{t("Confirm password")}
|
||
<input
|
||
name="confirmPassword"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={confirmation}
|
||
onChange={(e) => setConfirmation(e.target.value)}
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
)}
|
||
{mode === "login" && (
|
||
<button
|
||
type="button"
|
||
className="text-link auth-recovery"
|
||
disabled={busy}
|
||
onClick={() => changeMode("recover")}
|
||
>
|
||
{t("Forgot password?")}
|
||
</button>
|
||
)}
|
||
{error && (
|
||
<p role="alert" className="error-message">
|
||
{t(error)}
|
||
</p>
|
||
)}
|
||
{message && (
|
||
<p role="status" className="success-message">
|
||
{t(message)}
|
||
</p>
|
||
)}
|
||
<button className="button primary full" disabled={busy}>
|
||
{mode === "recover" ? (
|
||
<Mail size={17} />
|
||
) : (
|
||
<ArrowRight size={17} />
|
||
)}{" "}
|
||
{busy
|
||
? t("Please wait…")
|
||
: {
|
||
login: t("Log in"),
|
||
signup: t("Create account"),
|
||
recover: t("Send reset link"),
|
||
}[mode]}
|
||
</button>
|
||
</form>
|
||
{mode === "recover" && (
|
||
<button
|
||
type="button"
|
||
className="text-link auth-back"
|
||
disabled={busy}
|
||
onClick={() => changeMode("login")}
|
||
>
|
||
{t("← Back to log in")}
|
||
</button>
|
||
)}
|
||
<p className="auth-footnote">
|
||
{t(
|
||
"Your email stays private. Your display name appears with your maps.",
|
||
)}
|
||
</p>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|