feat: add Login, Register, and Join campaign pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 00:38:45 -04:00
parent 075a9c5505
commit e2548ad660
6 changed files with 432 additions and 3 deletions

View file

@ -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);
}

View file

@ -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() { export default function JoinPage() {
return <div>Join</div>; 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 (
<div className={styles.page}>
<div className={styles.card}>
{status === "joining" && <div className={styles.title}>Joining campaign</div>}
{status === "error" && (
<>
<div className={styles.title}>Invalid Invite</div>
<div className={styles.error}>{error}</div>
</>
)}
</div>
</div>
);
} }

View file

@ -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);
}

View file

@ -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() { export default function LoginPage() {
return <div>Login</div>; 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 (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.title}>Sign In</div>
<form onSubmit={handleSubmit}>
<div className={styles.field}>
<label className={styles.label}>Email</label>
<input
className={styles.input}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
required
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Password</label>
<input
className={styles.input}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button className={styles.btn} type="submit" disabled={loading}>
{loading ? "Signing in…" : "Sign In"}
</button>
</form>
<div className={styles.link}>
No account? <Link to="/register">Create one</Link>
</div>
</div>
</div>
);
} }

View file

@ -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);
}

View file

@ -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() { export default function RegisterPage() {
return <div>Register</div>; 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 (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.title}>Create Account</div>
<form onSubmit={handleSubmit}>
<div className={styles.field}>
<label className={styles.label}>Email</label>
<input
className={styles.input}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
required
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Username</label>
<input
className={styles.input}
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Password</label>
<input
className={styles.input}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button className={styles.btn} type="submit" disabled={loading}>
{loading ? "Creating account…" : "Create Account"}
</button>
</form>
<div className={styles.link}>
Already have an account? <Link to="/login">Sign in</Link>
</div>
</div>
</div>
);
} }