feat: add Login, Register, and Join campaign pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
075a9c5505
commit
e2548ad660
6 changed files with 432 additions and 3 deletions
84
client/src/pages/JoinPage.module.css
Normal file
84
client/src/pages/JoinPage.module.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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 <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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
84
client/src/pages/LoginPage.module.css
Normal file
84
client/src/pages/LoginPage.module.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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 <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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
84
client/src/pages/RegisterPage.module.css
Normal file
84
client/src/pages/RegisterPage.module.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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 <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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue