feat(auth): add registration page
This commit is contained in:
@ -22,6 +22,7 @@ import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
|||||||
import { Analytics } from "./pages/student/Analytics";
|
import { Analytics } from "./pages/student/Analytics";
|
||||||
import { QuestMap } from "./pages/student/QuestMap";
|
import { QuestMap } from "./pages/student/QuestMap";
|
||||||
import ErrorPage from "./pages/ErrorPage";
|
import ErrorPage from "./pages/ErrorPage";
|
||||||
|
import { Register } from "./pages/auth/Register";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -29,6 +30,10 @@ function App() {
|
|||||||
path: "/login",
|
path: "/login",
|
||||||
element: <Login />,
|
element: <Login />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/register",
|
||||||
|
element: <Register />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/student",
|
path: "/student",
|
||||||
element: <ProtectedRoute />,
|
element: <ProtectedRoute />,
|
||||||
|
|||||||
@ -175,6 +175,16 @@ const STYLES = `
|
|||||||
font-family: 'Nunito Sans', sans-serif;
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
font-size: 0.75rem; font-weight: 600; color: #9ca3af;
|
font-size: 0.75rem; font-weight: 600; color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
.rg-footer {
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.78rem; font-weight: 600; color: #9ca3af;
|
||||||
|
}
|
||||||
|
.rg-link {
|
||||||
|
color: #a855f7; font-weight: 800; text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.rg-link:hover { color: #7c3aed; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
@ -332,6 +342,12 @@ export const Login = () => {
|
|||||||
<p className="lg-footer">
|
<p className="lg-footer">
|
||||||
By signing in you agree to Edbridge's Terms & Privacy Policy.
|
By signing in you agree to Edbridge's Terms & Privacy Policy.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="rg-footer">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<a href="/register" className="rg-link">
|
||||||
|
Sign up
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
451
src/pages/auth/Register.tsx
Normal file
451
src/pages/auth/Register.tsx
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
|
import { Loader2, Mail, Lock, User, ImageIcon } from "lucide-react";
|
||||||
|
|
||||||
|
const DOTS = [
|
||||||
|
{ size: 12, color: "#f97316", top: "8%", left: "6%", delay: "0s" },
|
||||||
|
{ size: 7, color: "#a855f7", top: "22%", left: "3%", delay: "1.2s" },
|
||||||
|
{ size: 9, color: "#22c55e", top: "65%", left: "5%", delay: "0.6s" },
|
||||||
|
{ size: 8, color: "#f43f5e", top: "80%", left: "8%", delay: "2.1s" },
|
||||||
|
{ size: 12, color: "#3b82f6", top: "10%", right: "6%", delay: "1.8s" },
|
||||||
|
{ size: 7, color: "#eab308", top: "40%", right: "3%", delay: "0.9s" },
|
||||||
|
{ size: 10, color: "#a855f7", top: "72%", right: "5%", delay: "0.4s" },
|
||||||
|
{ size: 8, color: "#f97316", top: "55%", right: "8%", delay: "1.5s" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
.rg-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fffbf4;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blobs */
|
||||||
|
.rg-blob { position:fixed;pointer-events:none;z-index:0; }
|
||||||
|
.rg-blob-1 { width:280px;height:280px;background:#fde68a;top:-100px;left:-100px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:rgWobble1 14s ease-in-out infinite; }
|
||||||
|
.rg-blob-2 { width:220px;height:220px;background:#a5f3c0;bottom:-60px;left:4%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:rgWobble2 16s ease-in-out infinite; }
|
||||||
|
.rg-blob-3 { width:250px;height:250px;background:#fbcfe8;top:10%;right:-70px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:rgWobble1 18s ease-in-out infinite reverse; }
|
||||||
|
.rg-blob-4 { width:180px;height:180px;background:#bfdbfe;bottom:8%;right:0;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:rgWobble2 12s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes rgWobble1 {
|
||||||
|
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
|
||||||
|
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(14px,18px) rotate(8deg);}
|
||||||
|
}
|
||||||
|
@keyframes rgWobble2 {
|
||||||
|
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
|
||||||
|
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-12px,14px) rotate(-6deg);}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rg-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.28;animation:rgFloat 7s ease-in-out infinite; }
|
||||||
|
@keyframes rgFloat {
|
||||||
|
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||||
|
50%{transform:translateY(-14px) rotate(180deg);}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.rg-card {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 100%; max-width: 400px;
|
||||||
|
background: white; border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04);
|
||||||
|
padding: 2.25rem 2rem 2rem;
|
||||||
|
display: flex; flex-direction: column; gap: 1.75rem;
|
||||||
|
animation: rgPopIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes rgPopIn {
|
||||||
|
from { opacity:0; transform:scale(0.9) translateY(20px); }
|
||||||
|
to { opacity:1; transform:scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo area */
|
||||||
|
.rg-logo-wrap {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.rg-title {
|
||||||
|
font-size: 1.5rem; font-weight: 900; color: #1e1b4b;
|
||||||
|
letter-spacing: -0.02em; text-align: center;
|
||||||
|
}
|
||||||
|
.rg-sub {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.82rem; font-weight: 600; color: #9ca3af;
|
||||||
|
text-align: center; margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar preview */
|
||||||
|
.rg-avatar-preview {
|
||||||
|
width: 56px; height: 56px; border-radius: 50%;
|
||||||
|
border: 2.5px dashed #e5e7eb;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden; background: #f9fafb;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.rg-avatar-preview.has-image {
|
||||||
|
border-style: solid; border-color: #c4b5fd;
|
||||||
|
}
|
||||||
|
.rg-avatar-preview img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form fields */
|
||||||
|
.rg-fields { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.rg-field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.rg-label {
|
||||||
|
font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: #6b7280;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
.rg-input-wrap { position: relative; }
|
||||||
|
.rg-input-icon {
|
||||||
|
position: absolute; left: 0.85rem; top: 50%;
|
||||||
|
transform: translateY(-50%); pointer-events: none; color: #9ca3af;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.rg-input {
|
||||||
|
width: 100%; padding: 0.8rem 1rem 0.8rem 2.6rem;
|
||||||
|
background: #f9fafb; border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.88rem; font-weight: 600; color: #1e1b4b;
|
||||||
|
outline: none; transition: all 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.rg-input:focus {
|
||||||
|
background: white; border-color: #c4b5fd;
|
||||||
|
box-shadow: 0 0 0 3px rgba(168,85,247,0.1);
|
||||||
|
}
|
||||||
|
.rg-input:focus ~ .rg-input-icon { color: #a855f7; }
|
||||||
|
.rg-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.rg-input::placeholder { color: #d1d5db; }
|
||||||
|
|
||||||
|
/* Password strength */
|
||||||
|
.rg-strength-bar {
|
||||||
|
display: flex; gap: 4px; margin-top: 0.35rem; padding: 0 0.1rem;
|
||||||
|
}
|
||||||
|
.rg-strength-seg {
|
||||||
|
flex: 1; height: 4px; border-radius: 999px;
|
||||||
|
background: #f3f4f6; transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
.rg-strength-seg.active-weak { background: #f43f5e; }
|
||||||
|
.rg-strength-seg.active-medium { background: #eab308; }
|
||||||
|
.rg-strength-seg.active-strong { background: #22c55e; }
|
||||||
|
.rg-strength-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.72rem; font-weight: 700;
|
||||||
|
padding: 0 0.1rem; margin-top: 0.15rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.rg-strength-label.weak { color: #f43f5e; }
|
||||||
|
.rg-strength-label.medium { color: #eab308; }
|
||||||
|
.rg-strength-label.strong { color: #22c55e; }
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.rg-error {
|
||||||
|
background: #fff1f2; border: 2px solid #fecdd3;
|
||||||
|
border-radius: 14px; padding: 0.75rem 1rem;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.82rem; font-weight: 700; color: #e11d48;
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit button */
|
||||||
|
.rg-btn {
|
||||||
|
width: 100%; padding: 0.95rem;
|
||||||
|
background: #a855f7; color: white; border: none;
|
||||||
|
border-radius: 100px; cursor: pointer;
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.95rem; font-weight: 900;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
|
box-shadow: 0 6px 0 #7c3aed, 0 8px 20px rgba(168,85,247,0.25);
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||||
|
}
|
||||||
|
.rg-btn:hover { transform:translateY(-2px); box-shadow:0 8px 0 #7c3aed,0 12px 24px rgba(168,85,247,0.3); }
|
||||||
|
.rg-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #7c3aed; }
|
||||||
|
.rg-btn:disabled {
|
||||||
|
background: #e5e7eb; color: #9ca3af;
|
||||||
|
cursor: not-allowed; box-shadow: 0 4px 0 #d1d5db;
|
||||||
|
}
|
||||||
|
.rg-btn:disabled:hover { transform: none; box-shadow: 0 4px 0 #d1d5db; }
|
||||||
|
|
||||||
|
.rg-spinner { animation: rgSpin 0.8s linear infinite; }
|
||||||
|
@keyframes rgSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Sign-in link */
|
||||||
|
.rg-footer {
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.78rem; font-weight: 600; color: #9ca3af;
|
||||||
|
}
|
||||||
|
.rg-link {
|
||||||
|
color: #a855f7; font-weight: 800; text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.rg-link:hover { color: #7c3aed; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
function getPasswordStrength(password: string): 0 | 1 | 2 | 3 {
|
||||||
|
if (!password) return 0;
|
||||||
|
let score = 0;
|
||||||
|
if (password.length >= 8) score++;
|
||||||
|
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++;
|
||||||
|
if (/[0-9]/.test(password) || /[^A-Za-z0-9]/.test(password)) score++;
|
||||||
|
return score as 0 | 1 | 2 | 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRENGTH_LABELS = ["", "Weak", "Medium", "Strong"];
|
||||||
|
const STRENGTH_CLASSES = ["", "weak", "medium", "strong"];
|
||||||
|
|
||||||
|
export const Register = () => {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { register, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const strength = getPasswordStrength(password);
|
||||||
|
|
||||||
|
const handleAvatarChange = (url: string) => {
|
||||||
|
setAvatarUrl(url);
|
||||||
|
setAvatarError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
const success = await register({
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
avatar_url: avatarUrl,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (success) navigate("/student/home", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = name.trim() && email.trim() && password.length >= 6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rg-screen">
|
||||||
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
|
{/* Blobs */}
|
||||||
|
<div className="rg-blob rg-blob-1" />
|
||||||
|
<div className="rg-blob rg-blob-2" />
|
||||||
|
<div className="rg-blob rg-blob-3" />
|
||||||
|
<div className="rg-blob rg-blob-4" />
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{DOTS.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rg-dot"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: d.size,
|
||||||
|
height: d.size,
|
||||||
|
background: d.color,
|
||||||
|
top: d.top,
|
||||||
|
left: d.left,
|
||||||
|
right: d.right,
|
||||||
|
animationDelay: d.delay,
|
||||||
|
animationDuration: `${5.5 + i * 0.4}s`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="rg-card">
|
||||||
|
{/* Logo + heading */}
|
||||||
|
<div className="rg-logo-wrap">
|
||||||
|
<img
|
||||||
|
src="src/assets/ed_logo.png"
|
||||||
|
alt="EdBridge"
|
||||||
|
style={{
|
||||||
|
width: 600,
|
||||||
|
height: 70,
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Avatar preview */}
|
||||||
|
<div
|
||||||
|
className={`rg-avatar-preview ${avatarUrl && !avatarError ? "has-image" : ""}`}
|
||||||
|
>
|
||||||
|
{avatarUrl && !avatarError ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Avatar preview"
|
||||||
|
onError={() => setAvatarError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImageIcon size={22} color="#d1d5db" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="rg-title">Create account ✨</h1>
|
||||||
|
<p className="rg-sub">Join EdBridge and start your SAT prep</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="rg-fields">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="rg-field">
|
||||||
|
<label className="rg-label" htmlFor="name">
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<div className="rg-input-wrap">
|
||||||
|
<User size={15} className="rg-input-icon" />
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
className="rg-input"
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="rg-field">
|
||||||
|
<label className="rg-label" htmlFor="email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="rg-input-wrap">
|
||||||
|
<Mail size={15} className="rg-input-icon" />
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
className="rg-input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar URL */}
|
||||||
|
<div className="rg-field">
|
||||||
|
<label className="rg-label" htmlFor="avatarUrl">
|
||||||
|
Avatar URL{" "}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "none",
|
||||||
|
letterSpacing: 0,
|
||||||
|
color: "#c4b5fd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
(optional)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="rg-input-wrap">
|
||||||
|
<ImageIcon size={15} className="rg-input-icon" />
|
||||||
|
<input
|
||||||
|
id="avatarUrl"
|
||||||
|
type="url"
|
||||||
|
className="rg-input"
|
||||||
|
placeholder="https://example.com/photo.jpg"
|
||||||
|
value={avatarUrl}
|
||||||
|
onChange={(e) => handleAvatarChange(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="rg-field">
|
||||||
|
<label className="rg-label" htmlFor="password">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="rg-input-wrap">
|
||||||
|
<Lock size={15} className="rg-input-icon" />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
className="rg-input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Strength bar */}
|
||||||
|
{password && (
|
||||||
|
<>
|
||||||
|
<div className="rg-strength-bar">
|
||||||
|
{[1, 2, 3].map((seg) => (
|
||||||
|
<div
|
||||||
|
key={seg}
|
||||||
|
className={`rg-strength-seg ${
|
||||||
|
strength >= seg
|
||||||
|
? `active-${STRENGTH_CLASSES[strength]}`
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`rg-strength-label ${STRENGTH_CLASSES[strength]}`}
|
||||||
|
>
|
||||||
|
{STRENGTH_LABELS[strength]} password
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rg-error">
|
||||||
|
<span>⚠️</span> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
className="rg-btn"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isLoading || !isValid}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="rg-spinner" /> Creating account...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create Account →"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="rg-footer">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/login" className="rg-link">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1807,6 +1807,7 @@ export const Test = () => {
|
|||||||
<Binary size={18} className="mr-2" /> Go To Question
|
<Binary size={18} className="mr-2" /> Go To Question
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isTargeted && (
|
{!isTargeted && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="rounded-xl font-bold py-3 px-4 cursor-pointer"
|
className="rounded-xl font-bold py-3 px-4 cursor-pointer"
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import { api, type User, type LoginRequest } from "../utils/api";
|
import {
|
||||||
|
api,
|
||||||
|
type User,
|
||||||
|
type LoginRequest,
|
||||||
|
type RegistrationRequest,
|
||||||
|
} from "../utils/api";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@ -8,7 +13,9 @@ interface AuthState {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
registrationMessage: string | null;
|
||||||
login: (credentials: LoginRequest) => Promise<boolean>;
|
login: (credentials: LoginRequest) => Promise<boolean>;
|
||||||
|
register: (credentials: RegistrationRequest) => Promise<boolean>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
@ -21,6 +28,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
registrationMessage: null,
|
||||||
|
|
||||||
login: async (credentials: LoginRequest) => {
|
login: async (credentials: LoginRequest) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
@ -51,6 +59,31 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
register: async (credentials: RegistrationRequest) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.register(credentials);
|
||||||
|
|
||||||
|
set({
|
||||||
|
registrationMessage: response.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Registration failed";
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
set({
|
set({
|
||||||
@ -70,6 +103,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
token: state.token,
|
token: state.token,
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
}),
|
}),
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,6 +22,12 @@ export interface LoginRequest {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
export interface RegistrationRequest {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
email: string;
|
email: string;
|
||||||
@ -96,6 +102,14 @@ class ApiClient {
|
|||||||
body: JSON.stringify(credentials),
|
body: JSON.stringify(credentials),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async register(
|
||||||
|
credentials: RegistrationRequest,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>("/auth/register/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(credentials),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticated request helper
|
// Authenticated request helper
|
||||||
async authenticatedRequest<T>(
|
async authenticatedRequest<T>(
|
||||||
|
|||||||
Reference in New Issue
Block a user