- Add AuthModal to root layout driven by ?modal=auth search param - Update multiplayer and play beforeLoad redirects to use modal - Update NavAuth and Hero links to use modal - Delete login route and NavLogin component
249 lines
8 KiB
TypeScript
249 lines
8 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { toast } from "sonner";
|
|
import { authClient } from "../../lib/auth-client";
|
|
|
|
type Tab = "login" | "register";
|
|
|
|
type AuthModalProps = { onClose: () => void; onSuccess: () => void };
|
|
|
|
type LoginFormProps = { onSuccess: () => void };
|
|
|
|
const LoginForm = ({ onSuccess }: LoginFormProps) => {
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [isPending, setIsPending] = useState(false);
|
|
|
|
const handleSubmit = async () => {
|
|
setIsPending(true);
|
|
await authClient.signIn.email(
|
|
{ email, password },
|
|
{
|
|
onSuccess: () => {
|
|
toast.success("Welcome back!");
|
|
onSuccess();
|
|
},
|
|
onError: (ctx) => {
|
|
toast.error(ctx.error.message ?? "Something went wrong.");
|
|
setIsPending(false);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void handleSubmit();
|
|
}}
|
|
className="flex flex-col gap-3"
|
|
>
|
|
<input
|
|
type="email"
|
|
placeholder="Email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
/>
|
|
<input
|
|
type="password"
|
|
placeholder="Password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
/>
|
|
<div className="text-right">
|
|
<a
|
|
href="/forgot-password"
|
|
className="text-xs text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
|
>
|
|
Forgot password?
|
|
</a>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
|
|
>
|
|
{isPending ? "Logging in..." : "Login"}
|
|
</button>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
type RegisterFormProps = { onSuccess: () => void };
|
|
|
|
const RegisterForm = ({ onSuccess }: RegisterFormProps) => {
|
|
const [name, setName] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [isPending, setIsPending] = useState(false);
|
|
|
|
const handleSubmit = async () => {
|
|
setIsPending(true);
|
|
await authClient.signUp.email(
|
|
{ name, email, password },
|
|
{
|
|
onSuccess: () => {
|
|
toast.success("Check your email to verify your account.");
|
|
onSuccess();
|
|
},
|
|
onError: (ctx) => {
|
|
toast.error(ctx.error.message ?? "Something went wrong.");
|
|
setIsPending(false);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void handleSubmit();
|
|
}}
|
|
className="flex flex-col gap-3"
|
|
>
|
|
<input
|
|
type="text"
|
|
placeholder="Name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
/>
|
|
<input
|
|
type="email"
|
|
placeholder="Email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
/>
|
|
<input
|
|
type="password"
|
|
placeholder="Password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
minLength={8}
|
|
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
|
|
>
|
|
{isPending ? "Creating account..." : "Register"}
|
|
</button>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
type SocialButtonsProps = { onSuccess: () => void };
|
|
|
|
const SocialButtons = ({ onSuccess }: SocialButtonsProps) => {
|
|
const handleSocial = (provider: "google" | "github") => {
|
|
void authClient.signIn.social(
|
|
{ provider, callbackURL: window.location.origin },
|
|
{
|
|
onSuccess,
|
|
onError: (ctx) => {
|
|
toast.error(ctx.error.message ?? "Something went wrong.");
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 h-px bg-(--color-primary-light)" />
|
|
<span className="text-xs text-(--color-text-muted) font-medium">
|
|
or continue with
|
|
</span>
|
|
<div className="flex-1 h-px bg-(--color-primary-light)" />
|
|
</div>
|
|
<button
|
|
onClick={() => handleSocial("github")}
|
|
className="w-full rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
|
|
>
|
|
Continue with GitHub
|
|
</button>
|
|
<button
|
|
onClick={() => handleSocial("google")}
|
|
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
|
>
|
|
Continue with Google
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const AuthModal = ({ onClose, onSuccess }: AuthModalProps) => {
|
|
const [tab, setTab] = useState<Tab>("login");
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="relative w-full max-w-sm rounded-3xl border border-(--color-primary-light) bg-white shadow-lg p-8 flex flex-col gap-6"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Close button */}
|
|
<button
|
|
onClick={onClose}
|
|
className="absolute top-4 right-4 text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
|
aria-label="Close"
|
|
>
|
|
✕
|
|
</button>
|
|
|
|
{/* Header */}
|
|
<div className="text-center">
|
|
<h2 className="text-2xl font-black tracking-tight text-(--color-text)">
|
|
lila
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex rounded-2xl border border-(--color-primary-light) overflow-hidden">
|
|
{(["login", "register"] as Tab[]).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={`flex-1 py-2 text-sm font-bold transition-colors capitalize ${
|
|
tab === t
|
|
? "bg-(--color-primary) text-white"
|
|
: "text-(--color-text-muted) hover:text-(--color-primary)"
|
|
}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === "login" ? (
|
|
<LoginForm onSuccess={onSuccess} />
|
|
) : (
|
|
<RegisterForm onSuccess={onClose} />
|
|
)}
|
|
|
|
{/* Social */}
|
|
<SocialButtons onSuccess={onSuccess} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|