From e2548ad6606a037c85e813e3b665e6b7b1b34d8b Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 00:38:45 -0400 Subject: [PATCH] feat: add Login, Register, and Join campaign pages Co-Authored-By: Claude Sonnet 4.6 --- client/src/pages/JoinPage.module.css | 84 ++++++++++++++++++++++++ client/src/pages/JoinPage.tsx | 36 +++++++++- client/src/pages/LoginPage.module.css | 84 ++++++++++++++++++++++++ client/src/pages/LoginPage.tsx | 66 ++++++++++++++++++- client/src/pages/RegisterPage.module.css | 84 ++++++++++++++++++++++++ client/src/pages/RegisterPage.tsx | 81 ++++++++++++++++++++++- 6 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 client/src/pages/JoinPage.module.css create mode 100644 client/src/pages/LoginPage.module.css create mode 100644 client/src/pages/RegisterPage.module.css diff --git a/client/src/pages/JoinPage.module.css b/client/src/pages/JoinPage.module.css new file mode 100644 index 0000000..270b95f --- /dev/null +++ b/client/src/pages/JoinPage.module.css @@ -0,0 +1,84 @@ +.page { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; + width: 100%; + max-width: 380px; +} + +.title { + font-size: 1.4rem; + font-weight: 700; + margin: 0 0 1.5rem; + color: var(--accent); +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 1rem; +} + +.label { + font-size: 0.85rem; + color: var(--text-muted, #aaa); +} + +.input { + background: var(--input-bg, #1a1a1a); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 0.95rem; + padding: 0.5rem 0.75rem; +} + +.input:focus { + outline: none; + border-color: var(--accent); +} + +.error { + color: #e55; + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + +.btn { + width: 100%; + padding: 0.6rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.5rem; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.link { + display: block; + text-align: center; + margin-top: 1rem; + font-size: 0.85rem; + color: var(--text-muted, #aaa); +} + +.link a { + color: var(--accent); +} diff --git a/client/src/pages/JoinPage.tsx b/client/src/pages/JoinPage.tsx index 2a684e1..8a03b7e 100644 --- a/client/src/pages/JoinPage.tsx +++ b/client/src/pages/JoinPage.tsx @@ -1,3 +1,37 @@ +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { joinCampaign } from "../api"; +import styles from "./JoinPage.module.css"; + export default function JoinPage() { - return
Join
; + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const [status, setStatus] = useState<"joining" | "error">("joining"); + const [error, setError] = useState(""); + + useEffect(() => { + if (!token) return; + joinCampaign(token) + .then(({ campaignId }) => { + navigate(`/campaign/${campaignId}`); + }) + .catch((err) => { + setStatus("error"); + setError(err instanceof Error ? err.message : "Invalid invite link"); + }); + }, [token, navigate]); + + return ( +
+
+ {status === "joining" &&
Joining campaign…
} + {status === "error" && ( + <> +
Invalid Invite
+
{error}
+ + )} +
+
+ ); } diff --git a/client/src/pages/LoginPage.module.css b/client/src/pages/LoginPage.module.css new file mode 100644 index 0000000..270b95f --- /dev/null +++ b/client/src/pages/LoginPage.module.css @@ -0,0 +1,84 @@ +.page { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; + width: 100%; + max-width: 380px; +} + +.title { + font-size: 1.4rem; + font-weight: 700; + margin: 0 0 1.5rem; + color: var(--accent); +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 1rem; +} + +.label { + font-size: 0.85rem; + color: var(--text-muted, #aaa); +} + +.input { + background: var(--input-bg, #1a1a1a); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 0.95rem; + padding: 0.5rem 0.75rem; +} + +.input:focus { + outline: none; + border-color: var(--accent); +} + +.error { + color: #e55; + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + +.btn { + width: 100%; + padding: 0.6rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.5rem; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.link { + display: block; + text-align: center; + margin-top: 1rem; + font-size: 0.85rem; + color: var(--text-muted, #aaa); +} + +.link a { + color: var(--accent); +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index ba5b453..fe361e7 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,3 +1,67 @@ +import { useState } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { login } from "../api"; +import { useAuth } from "../context/AuthContext"; +import styles from "./LoginPage.module.css"; + export default function LoginPage() { - return
Login
; + const { setUser } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const user = await login(email, password); + setUser(user); + navigate("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
Sign In
+
+
+ + setEmail(e.target.value)} + autoFocus + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&
{error}
} + +
+
+ No account? Create one +
+
+
+ ); } diff --git a/client/src/pages/RegisterPage.module.css b/client/src/pages/RegisterPage.module.css new file mode 100644 index 0000000..270b95f --- /dev/null +++ b/client/src/pages/RegisterPage.module.css @@ -0,0 +1,84 @@ +.page { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; + width: 100%; + max-width: 380px; +} + +.title { + font-size: 1.4rem; + font-weight: 700; + margin: 0 0 1.5rem; + color: var(--accent); +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 1rem; +} + +.label { + font-size: 0.85rem; + color: var(--text-muted, #aaa); +} + +.input { + background: var(--input-bg, #1a1a1a); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 0.95rem; + padding: 0.5rem 0.75rem; +} + +.input:focus { + outline: none; + border-color: var(--accent); +} + +.error { + color: #e55; + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + +.btn { + width: 100%; + padding: 0.6rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.5rem; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.link { + display: block; + text-align: center; + margin-top: 1rem; + font-size: 0.85rem; + color: var(--text-muted, #aaa); +} + +.link a { + color: var(--accent); +} diff --git a/client/src/pages/RegisterPage.tsx b/client/src/pages/RegisterPage.tsx index 102d218..17da7e1 100644 --- a/client/src/pages/RegisterPage.tsx +++ b/client/src/pages/RegisterPage.tsx @@ -1,3 +1,82 @@ +import { useState } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { register } from "../api"; +import { useAuth } from "../context/AuthContext"; +import styles from "./RegisterPage.module.css"; + export default function RegisterPage() { - return
Register
; + const { setUser } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (password.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + setLoading(true); + try { + const user = await register(email, username, password); + setUser(user); + navigate("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Registration failed"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
Create Account
+
+
+ + setEmail(e.target.value)} + autoFocus + required + /> +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&
{error}
} + +
+
+ Already have an account? Sign in +
+
+
+ ); }