AuthPage.tsx
4,978 bytes
| 1 | import { useState, type FormEvent } from "react"; |
|---|---|
| 2 | import { ArrowLeft, ArrowRight, LoaderCircle, Stamp } from "lucide-react"; |
| 3 | import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom"; |
| 4 | import { Logo } from "../components/Logo"; |
| 5 | import { useAuth } from "../lib/auth"; |
| 6 | import { ApiError } from "../lib/api"; |
| 7 | |
| 8 | export function AuthPage({ mode }: { mode: "login" | "register" }) { |
| 9 | const { user, loading, login, register } = useAuth(); |
| 10 | const [params] = useSearchParams(); |
| 11 | const navigate = useNavigate(); |
| 12 | const location = useLocation(); |
| 13 | const [displayName, setDisplayName] = useState(""); |
| 14 | const [email, setEmail] = useState(params.get("demo") === "true" ? "demo@tasteprint.app" : ""); |
| 15 | const [password, setPassword] = useState(params.get("demo") === "true" ? "tasteprint" : ""); |
| 16 | const [submitting, setSubmitting] = useState(false); |
| 17 | const [error, setError] = useState(""); |
| 18 | |
| 19 | if (!loading && user) { |
| 20 | return <Navigate to={params.get("next") || "/app"} replace />; |
| 21 | } |
| 22 | |
| 23 | async function submit(event: FormEvent) { |
| 24 | event.preventDefault(); |
| 25 | setSubmitting(true); |
| 26 | setError(""); |
| 27 | try { |
| 28 | if (mode === "register") { |
| 29 | await register(displayName, email, password); |
| 30 | } else { |
| 31 | await login(email, password); |
| 32 | } |
| 33 | navigate(params.get("next") || "/app", { replace: true }); |
| 34 | } catch (caught) { |
| 35 | setError(caught instanceof ApiError ? caught.message : "Sign in could not be completed."); |
| 36 | } finally { |
| 37 | setSubmitting(false); |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | async function openDemo() { |
| 42 | setEmail("demo@tasteprint.app"); |
| 43 | setPassword("tasteprint"); |
| 44 | setSubmitting(true); |
| 45 | setError(""); |
| 46 | try { |
| 47 | await login("demo@tasteprint.app", "tasteprint"); |
| 48 | navigate(params.get("next") || "/app", { replace: true }); |
| 49 | } catch (caught) { |
| 50 | setError(caught instanceof ApiError ? caught.message : "The demo could not be opened."); |
| 51 | } finally { |
| 52 | setSubmitting(false); |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | return ( |
| 57 | <main className="auth-page"> |
| 58 | <section className="auth-story"> |
| 59 | <Logo /> |
| 60 | <div> |
| 61 | <span className="eyebrow">Your edible travel record</span> |
| 62 | <h1>Every destination leaves a different mark.</h1> |
| 63 | <div className="auth-stamps" aria-hidden="true"> |
| 64 | <span className="auth-stamp auth-stamp-jp">JP<small>44%</small></span> |
| 65 | <span className="auth-stamp auth-stamp-pt">PT<small>68%</small></span> |
| 66 | <span className="auth-stamp auth-stamp-mx">MX<small>35%</small></span> |
| 67 | </div> |
| 68 | </div> |
| 69 | <p>Built for travellers who remember a city by what was on the table.</p> |
| 70 | </section> |
| 71 | |
| 72 | <section className="auth-panel"> |
| 73 | <Link className="back-link" to="/"><ArrowLeft size={16} /> Back</Link> |
| 74 | <div className="auth-form-wrap"> |
| 75 | <span className="auth-seal"><Stamp size={24} /></span> |
| 76 | <span className="eyebrow">{mode === "login" ? "Welcome back" : "Issue your passport"}</span> |
| 77 | <h2>{mode === "login" ? "Continue your food map" : "Start your Tasteprint"}</h2> |
| 78 | |
| 79 | <form className="auth-form" onSubmit={submit}> |
| 80 | {mode === "register" && ( |
| 81 | <label> |
| 82 | <span>Name</span> |
| 83 | <input value={displayName} onChange={event => setDisplayName(event.target.value)} |
| 84 | autoComplete="name" maxLength={80} required /> |
| 85 | </label> |
| 86 | )} |
| 87 | <label> |
| 88 | <span>Email</span> |
| 89 | <input type="email" value={email} onChange={event => setEmail(event.target.value)} |
| 90 | autoComplete="email" required /> |
| 91 | </label> |
| 92 | <label> |
| 93 | <span>Password</span> |
| 94 | <input type="password" value={password} onChange={event => setPassword(event.target.value)} |
| 95 | autoComplete={mode === "login" ? "current-password" : "new-password"} |
| 96 | minLength={8} maxLength={72} required /> |
| 97 | </label> |
| 98 | {error && <p className="form-error" role="alert">{error}</p>} |
| 99 | <button className="button button-primary button-full" type="submit" disabled={submitting}> |
| 100 | {submitting ? <LoaderCircle className="spin" size={18} /> : <ArrowRight size={18} />} |
| 101 | {mode === "login" ? "Sign in" : "Create passport"} |
| 102 | </button> |
| 103 | </form> |
| 104 | |
| 105 | {mode === "login" && ( |
| 106 | <button className="button button-outline button-full" type="button" onClick={() => void openDemo()} disabled={submitting}> |
| 107 | Open demo passport |
| 108 | </button> |
| 109 | )} |
| 110 | |
| 111 | <p className="auth-switch"> |
| 112 | {mode === "login" ? "New here?" : "Already have a passport?"}{" "} |
| 113 | <Link to={mode === "login" ? "/register" : "/login"} state={{ from: location }}> |
| 114 | {mode === "login" ? "Create an account" : "Sign in"} |
| 115 | </Link> |
| 116 | </p> |
| 117 | </div> |
| 118 | </section> |
| 119 | </main> |
| 120 | ); |
| 121 | } |
| 122 | |