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
+
+
+ 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
+
+
+ Already have an account? Sign in
+
+
+
+ );
}