Files
edbridge-scholars/src/pages/auth/Register.tsx

1023 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useCallback } from "react";
import type { FormEvent, DragEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "../../stores/authStore";
import {
Loader2,
Mail,
Lock,
BookOpen,
Star,
Zap,
Trophy,
Upload,
X,
Camera,
} from "lucide-react";
import { api } from "../../utils/api";
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');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.rg-root {
min-height: 100vh;
display: flex;
font-family: 'Nunito', sans-serif;
background: #fffbf4;
}
/* ─── LEFT PANEL ─── */
.rg-left {
position: relative;
width: 50%;
min-height: 100vh;
background: linear-gradient(150deg, #1e1b4b 0%, #3b1d8a 50%, #6d28d9 100%);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 3rem 3.5rem;
overflow: hidden;
flex-shrink: 0;
}
.rg-panel-blob {
position: absolute; pointer-events: none; border-radius: 50%;
opacity: 0.18;
}
.rg-panel-blob-1 {
width: 420px; height: 420px;
background: #a855f7;
top: -140px; right: -100px;
animation: blobDrift1 16s ease-in-out infinite;
}
.rg-panel-blob-2 {
width: 300px; height: 300px;
background: #f97316;
bottom: -100px; left: -80px;
animation: blobDrift2 18s ease-in-out infinite;
}
.rg-panel-blob-3 {
width: 200px; height: 200px;
background: #22c55e;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
animation: blobDrift1 14s ease-in-out infinite reverse;
}
@keyframes blobDrift1 {
0%,100% { transform: translate(0,0) scale(1); }
50% { transform: translate(24px, 32px) scale(1.08); }
}
@keyframes blobDrift2 {
0%,100% { transform: translate(0,0) scale(1); }
50% { transform: translate(-20px, -28px) scale(1.06); }
}
.rg-shape {
position: absolute; pointer-events: none;
animation: floatShape 8s ease-in-out infinite;
}
@keyframes floatShape {
0%,100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-16px) rotate(12deg); }
}
.rg-star {
position: absolute; pointer-events: none;
color: #fde68a; opacity: 0.55;
animation: twinkle 3s ease-in-out infinite;
}
@keyframes twinkle {
0%,100% { opacity: 0.55; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.3); }
}
.rg-panel-content {
position: relative; z-index: 1;
display: flex; flex-direction: column;
align-items: flex-start; gap: 2.5rem;
width: 100%;
}
.rg-panel-logo {
display: flex; align-items: center; gap: 0.75rem;
}
.rg-panel-logo-badge {
width: 48px; height: 48px; border-radius: 14px;
background: linear-gradient(135deg, #f97316, #ef4444);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 6px 0 rgba(0,0,0,0.25), 0 8px 20px rgba(249,115,22,0.4);
font-size: 1.4rem;
}
.rg-panel-logo-text {
font-size: 1.4rem; font-weight: 900;
color: white; letter-spacing: -0.02em;
}
.rg-panel-headline {
display: flex; flex-direction: column; gap: 0.75rem;
}
.rg-panel-headline h2 {
font-size: 2.4rem; font-weight: 900; line-height: 1.15;
color: white; letter-spacing: -0.03em;
}
.rg-panel-headline h2 span {
background: linear-gradient(90deg, #fde68a, #f97316);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.rg-panel-headline p {
font-family: 'Nunito Sans', sans-serif;
font-size: 1rem; font-weight: 600; color: #c4b5fd; line-height: 1.6;
}
.rg-features { display: flex; flex-direction: column; gap: 0.85rem; }
.rg-feature {
display: flex; align-items: center; gap: 0.85rem;
background: rgba(255,255,255,0.07);
border: 1.5px solid rgba(255,255,255,0.12);
border-radius: 14px; padding: 0.85rem 1.1rem;
backdrop-filter: blur(8px);
animation: fadeSlideIn 0.5s ease both;
}
.rg-feature:nth-child(1) { animation-delay: 0.1s; }
.rg-feature:nth-child(2) { animation-delay: 0.2s; }
.rg-feature:nth-child(3) { animation-delay: 0.3s; }
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
.rg-feature-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.rg-feature-text strong {
display: block; font-size: 0.85rem; font-weight: 800; color: white;
}
.rg-feature-text span {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.75rem; font-weight: 600; color: #a5b4fc;
}
.rg-social-proof {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.1rem 0;
}
.rg-avatars { display: flex; }
.rg-av {
width: 30px; height: 30px; border-radius: 50%;
border: 2px solid #3b1d8a;
background: linear-gradient(135deg, #a855f7, #6d28d9);
margin-left: -8px; display: flex; align-items: center;
justify-content: center; font-size: 0.65rem; font-weight: 800; color: white;
}
.rg-av:first-child { margin-left: 0; }
.rg-social-proof p {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.78rem; font-weight: 700; color: #c4b5fd;
}
.rg-social-proof p strong { color: #fde68a; }
/* ─── RIGHT PANEL ─── */
.rg-right {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 3rem 4rem;
position: relative; overflow: hidden;
}
.rg-bg-dot {
position: absolute; border-radius: 50%; pointer-events: none; opacity: 0.10;
animation: bgDotFloat 9s ease-in-out infinite;
}
@keyframes bgDotFloat {
0%,100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.rg-form-wrap {
position: relative; z-index: 1;
width: 100%; max-width: 420px;
display: flex; flex-direction: column; gap: 2rem;
animation: formPopIn 0.55s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes formPopIn {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.rg-form-header { display: flex; flex-direction: column; gap: 0.4rem; }
.rg-form-header h1 {
font-size: 2rem; font-weight: 900; color: #1e1b4b;
letter-spacing: -0.03em; line-height: 1.2;
}
.rg-form-header p {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.88rem; font-weight: 600; color: #9ca3af;
}
/* ─── AVATAR UPLOAD ─── */
.av-upload-wrap {
display: flex;
align-items: center;
gap: 1.25rem;
}
/* The circular avatar preview */
.av-circle {
position: relative;
width: 72px; height: 72px;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
}
.av-circle-inner {
width: 100%; height: 100%;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #ede9fe, #faf5ff);
border: 2.5px solid #e5e7eb;
display: flex; align-items: center; justify-content: center;
transition: border-color 0.25s, box-shadow 0.25s;
position: relative;
}
.av-circle:hover .av-circle-inner {
border-color: #a855f7;
box-shadow: 0 0 0 4px rgba(168,85,247,0.12);
}
.av-circle-inner img {
width: 100%; height: 100%; object-fit: cover;
}
.av-circle-inner.has-image {
border-color: #a855f7;
border-style: solid;
box-shadow: 0 0 0 3px rgba(168,85,247,0.15);
}
/* Camera overlay on hover */
.av-circle-overlay {
position: absolute; inset: 0; border-radius: 50%;
background: rgba(109,40,217,0.55);
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.2s;
pointer-events: none;
}
.av-circle:hover .av-circle-overlay { opacity: 1; }
/* Remove button */
.av-remove-btn {
position: absolute;
top: -3px; right: -3px;
width: 20px; height: 20px;
border-radius: 50%;
background: #ef4444;
border: 2px solid white;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
transition: transform 0.15s, background 0.15s;
z-index: 2;
}
.av-remove-btn:hover { background: #dc2626; transform: scale(1.15); }
/* Drop zone (right side) */
.av-dropzone {
flex: 1;
border: 2px dashed #e5e7eb;
border-radius: 16px;
padding: 0.9rem 1.1rem;
display: flex; flex-direction: column;
align-items: flex-start; gap: 0.35rem;
background: #fafafa;
cursor: pointer;
transition: border-color 0.2s, background 0.2s, transform 0.15s;
position: relative;
overflow: hidden;
}
.av-dropzone:hover, .av-dropzone.dragover {
border-color: #a855f7;
background: #fdf4ff;
transform: scale(1.01);
}
.av-dropzone.dragover {
border-style: solid;
box-shadow: 0 0 0 4px rgba(168,85,247,0.1);
}
.av-dropzone-title {
font-size: 0.82rem; font-weight: 800; color: #1e1b4b;
display: flex; align-items: center; gap: 0.4rem;
}
.av-dropzone-sub {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.7rem; font-weight: 600; color: #9ca3af;
}
.av-dropzone-types {
display: flex; gap: 0.3rem; margin-top: 0.1rem;
}
.av-type-badge {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.62rem; font-weight: 700;
padding: 0.15rem 0.45rem;
border-radius: 6px;
background: #ede9fe; color: #7c3aed;
letter-spacing: 0.04em; text-transform: uppercase;
}
/* Shimmer drag-over effect */
.av-dropzone.dragover::after {
content: '';
position: absolute; inset: 0;
background: linear-gradient(120deg, transparent 30%, rgba(168,85,247,0.07) 50%, transparent 70%);
animation: shimmer 1s linear infinite;
}
@keyframes shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
/* Upload progress bar */
.av-progress-wrap {
width: 100%;
height: 4px; border-radius: 999px;
background: #f3f4f6; overflow: hidden;
margin-top: 0.4rem;
}
.av-progress-bar {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, #a855f7, #6d28d9);
transition: width 0.2s ease;
}
.av-uploading-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.7rem; font-weight: 700; color: #a855f7;
display: flex; align-items: center; gap: 0.3rem;
margin-top: 0.2rem;
}
.av-upload-spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Upload error */
.av-upload-error {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.7rem; font-weight: 700; color: #e11d48;
margin-top: 0.2rem;
}
/* Fields */
.rg-fields { display: flex; flex-direction: column; gap: 1rem; }
.rg-row { display: flex; gap: 1rem; }
.rg-row .rg-field { flex: 1; }
.rg-field { display: flex; flex-direction: column; gap: 0.4rem; }
.rg-label {
font-size: 0.7rem; font-weight: 800; letter-spacing: 0.1em;
text-transform: uppercase; color: #6b7280; padding-left: 0.2rem;
}
.rg-input-wrap { position: relative; }
.rg-input-icon {
position: absolute; left: 0.9rem; top: 50%;
transform: translateY(-50%); pointer-events: none; color: #9ca3af;
transition: color 0.2s;
}
.rg-input {
width: 100%; padding: 0.85rem 1rem 0.85rem 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;
}
.rg-input:focus {
background: white; border-color: #c4b5fd;
box-shadow: 0 0 0 3.5px 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; }
/* Strength */
.rg-strength-bar { display: flex; gap: 5px; margin-top: 0.4rem; }
.rg-strength-seg {
flex: 1; height: 4px; border-radius: 999px;
background: #f3f4f6; transition: background 0.3s;
}
.rg-strength-seg.weak { background: #f43f5e; }
.rg-strength-seg.medium { background: #eab308; }
.rg-strength-seg.strong { background: #22c55e; }
.rg-strength-hint {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.7rem; font-weight: 700; margin-top: 0.2rem; padding-left: 0.1rem;
color: #9ca3af;
}
.rg-strength-hint.weak { color: #f43f5e; }
.rg-strength-hint.medium { color: #eab308; }
.rg-strength-hint.strong { color: #22c55e; }
.rg-error {
background: #fff1f2; border: 2px solid #fecdd3;
border-radius: 14px; padding: 0.8rem 1rem;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.82rem; font-weight: 700; color: #e11d48;
display: flex; align-items: center; gap: 0.5rem;
}
.rg-btn {
width: 100%; padding: 1rem;
background: #a855f7; color: white; border: none;
border-radius: 100px; cursor: pointer;
font-family: 'Nunito', sans-serif; font-size: 1rem; font-weight: 900;
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
box-shadow: 0 6px 0 #7c3aed, 0 10px 24px rgba(168,85,247,0.3);
transition: transform 0.1s, box-shadow 0.1s;
letter-spacing: 0.01em;
}
.rg-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 0 #7c3aed, 0 14px 28px rgba(168,85,247,0.35); }
.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: spin 0.8s linear infinite; }
.rg-form-footer {
text-align: center;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.8rem; font-weight: 600; color: #9ca3af;
}
.rg-link {
color: #a855f7; font-weight: 800; text-decoration: none;
}
.rg-link:hover { color: #7c3aed; }
@media (max-width: 860px) {
.rg-left { display: none; }
.rg-right { padding: 2rem 1.5rem; }
}
`;
function getStrength(p: string): 0 | 1 | 2 | 3 {
if (!p) return 0;
let s = 0;
if (p.length >= 8) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/[0-9]/.test(p) || /[^A-Za-z0-9]/.test(p)) s++;
return s as 0 | 1 | 2 | 3;
}
const S_LABEL = ["", "Weak", "Medium", "Strong"];
const S_CLASS = ["", "weak", "medium", "strong"];
const FEATURES = [
{
icon: <BookOpen size={18} color="#fff" />,
bg: "#a855f7",
title: "Adaptive Practice",
sub: "Questions tailored to your skill level",
},
{
icon: <Zap size={18} color="#fff" />,
bg: "#f97316",
title: "Instant Feedback",
sub: "Know exactly where you went wrong",
},
{
icon: <Trophy size={18} color="#fff" />,
bg: "#22c55e",
title: "Score Tracking",
sub: "Watch your SAT score climb over time",
},
];
const PANEL_DOTS = [
{
size: 70,
color: "#f97316",
top: "15%",
left: "65%",
delay: "0s",
dur: "9s",
},
{
size: 45,
color: "#22c55e",
top: "62%",
left: "10%",
delay: "1s",
dur: "11s",
},
{
size: 30,
color: "#fde68a",
top: "35%",
left: "75%",
delay: "0.5s",
dur: "7s",
},
{
size: 20,
color: "#a855f7",
top: "78%",
left: "50%",
delay: "2s",
dur: "13s",
},
];
const BG_DOTS = [
{
size: 180,
color: "#a855f7",
top: "5%",
right: "5%",
delay: "0s",
dur: "12s",
},
{
size: 100,
color: "#f97316",
bottom: "10%",
left: "2%",
delay: "1.5s",
dur: "10s",
},
{
size: 60,
color: "#22c55e",
top: "50%",
right: "3%",
delay: "0.8s",
dur: "8s",
},
];
const INITIALS = ["JD", "AS", "MK", "RP", "LL"];
const ACCEPTED = ["image/jpeg", "image/png", "image/webp", "image/gif"];
// ─── Avatar Upload Component ───────────────────────────────────────────────
interface AvatarUploadProps {
avatarUrl: string;
onAvatarChange: (url: string) => void;
disabled?: boolean;
}
function AvatarUpload({
avatarUrl,
onAvatarChange,
disabled,
}: AvatarUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const processFile = useCallback(
async (file: File) => {
if (!ACCEPTED.includes(file.type)) {
setUploadError("Please upload a JPG, PNG, WebP, or GIF image.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5 MB.");
return;
}
setUploadError("");
// Show local preview immediately
const local = URL.createObjectURL(file);
setPreviewUrl(local);
setIsUploading(true);
setUploadProgress(0);
// Fake progress ticks while uploading
const ticker = setInterval(() => {
setUploadProgress((p) => Math.min(p + 12, 85));
}, 120);
try {
const uploadedUrl = await api.uploadAvatar(file);
clearInterval(ticker);
setUploadProgress(100);
onAvatarChange(uploadedUrl);
setTimeout(() => {
setIsUploading(false);
setUploadProgress(0);
}, 600);
} catch {
clearInterval(ticker);
setIsUploading(false);
setUploadProgress(0);
setPreviewUrl("");
setUploadError("Upload failed. Please try again.");
}
},
[onAvatarChange],
);
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files[0];
if (file) processFile(file);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) processFile(file);
e.target.value = "";
};
const handleRemove = (ev: React.MouseEvent) => {
ev.stopPropagation();
setPreviewUrl("");
onAvatarChange("");
setUploadError("");
};
const displayUrl = previewUrl || avatarUrl;
return (
<div className="av-upload-wrap">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED.join(",")}
style={{ display: "none" }}
onChange={handleFileChange}
disabled={disabled || isUploading}
/>
{/* Avatar circle */}
<div
className="av-circle"
onClick={() =>
!disabled && !isUploading && fileInputRef.current?.click()
}
title="Click to change photo"
>
<div className={`av-circle-inner ${displayUrl ? "has-image" : ""}`}>
{displayUrl ? (
<img src={displayUrl} alt="Avatar preview" />
) : (
<Camera size={24} color="#c4b5fd" />
)}
<div className="av-circle-overlay">
<Camera size={18} color="white" />
</div>
</div>
{displayUrl && !isUploading && (
<div
className="av-remove-btn"
onClick={handleRemove}
title="Remove photo"
>
<X size={10} color="white" strokeWidth={3} />
</div>
)}
</div>
{/* Drop zone */}
<div
className={`av-dropzone ${isDragOver ? "dragover" : ""}`}
onClick={() =>
!disabled && !isUploading && fileInputRef.current?.click()
}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
{isUploading ? (
<>
<div className="av-uploading-label">
<Loader2 size={12} className="av-upload-spinner" />
Uploading photo
</div>
<div className="av-progress-wrap">
<div
className="av-progress-bar"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</>
) : (
<>
<div className="av-dropzone-title">
<Upload size={13} color="#a855f7" />
{displayUrl ? "Change photo" : "Upload a photo"}
</div>
<div className="av-dropzone-sub">
{isDragOver ? "Drop it here!" : "Click or drag & drop"}
</div>
<div className="av-dropzone-types">
{["PNG", "JPG", "WebP"].map((t) => (
<span key={t} className="av-type-badge">
{t}
</span>
))}
<span
className="av-type-badge"
style={{ background: "#fef3c7", color: "#b45309" }}
>
Max 5 MB
</span>
</div>
</>
)}
{uploadError && <div className="av-upload-error"> {uploadError}</div>}
</div>
</div>
);
}
// ─── Main Register Page ───────────────────────────────────────────────────
export const Register = () => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [password, setPassword] = useState("");
const navigate = useNavigate();
const { register, isLoading, error, clearError } = useAuthStore();
const strength = getStrength(password);
const isValid = name.trim() && email.trim() && password.length >= 6;
const handleSubmit = async (e: FormEvent<HTMLButtonElement>) => {
e.preventDefault();
clearError();
const success = await register({
email,
name,
avatar_url: avatarUrl,
password,
});
if (success) navigate("/login", { replace: true });
};
return (
<div className="rg-root">
<style>{STYLES}</style>
{/* ── LEFT PANEL ── */}
<div className="rg-left">
<div className="rg-panel-blob rg-panel-blob-1" />
<div className="rg-panel-blob rg-panel-blob-2" />
<div className="rg-panel-blob rg-panel-blob-3" />
{PANEL_DOTS.map((d, i) => (
<div
key={i}
className="rg-shape"
style={
{
width: d.size,
height: d.size,
background: d.color,
borderRadius: "50%",
opacity: 0.12,
top: d.top,
left: d.left,
animationDelay: d.delay,
animationDuration: d.dur,
} as React.CSSProperties
}
/>
))}
{[
{ top: "14%", left: "20%", size: 14, delay: "0s" },
{ top: "28%", left: "78%", size: 10, delay: "0.7s" },
{ top: "52%", left: "30%", size: 12, delay: "1.3s" },
{ top: "70%", left: "65%", size: 8, delay: "0.4s" },
{ top: "88%", left: "22%", size: 10, delay: "1.8s" },
].map((s, i) => (
<Star
key={i}
className="rg-star"
size={s.size}
style={
{
top: s.top,
left: s.left,
animationDelay: s.delay,
fill: "#fde68a",
} as React.CSSProperties
}
/>
))}
{[
{ size: 90, top: "72%", left: "5%", delay: "0.2s", dur: "10s" },
{ size: 60, top: "8%", left: "55%", delay: "1.1s", dur: "13s" },
].map((r, i) => (
<div
key={i}
className="rg-shape"
style={
{
width: r.size,
height: r.size,
border: "2.5px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
top: r.top,
left: r.left,
animationDelay: r.delay,
animationDuration: r.dur,
} as React.CSSProperties
}
/>
))}
<div className="rg-panel-content">
<div className="rg-panel-logo">
<div className="rg-panel-logo-badge">📚</div>
<span className="rg-panel-logo-text">EdBridge</span>
</div>
<div className="rg-panel-headline">
<h2>
Ace the SAT.
<br />
<span>Start for free.</span>
</h2>
<p>
Join thousands of students who improved their
<br />
SAT scores with personalized practice.
</p>
</div>
<div className="rg-features">
{FEATURES.map((f, i) => (
<div className="rg-feature" key={i}>
<div className="rg-feature-icon" style={{ background: f.bg }}>
{f.icon}
</div>
<div className="rg-feature-text">
<strong>{f.title}</strong>
<span>{f.sub}</span>
</div>
</div>
))}
</div>
<div className="rg-social-proof">
<div className="rg-avatars">
{INITIALS.map((s, i) => (
<div className="rg-av" key={i}>
{s}
</div>
))}
</div>
<p>
<strong>2,400+</strong> students already enrolled
</p>
</div>
</div>
</div>
{/* ── RIGHT PANEL ── */}
<div className="rg-right">
{BG_DOTS.map((d, i) => (
<div
key={i}
className="rg-bg-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: (d as any).top,
right: (d as any).right,
bottom: (d as any).bottom,
left: (d as any).left,
animationDelay: d.delay,
animationDuration: d.dur,
} as React.CSSProperties
}
/>
))}
<div className="rg-form-wrap">
<div className="rg-form-header">
<h1>Create your account </h1>
<p>Fill in the details below to get started</p>
</div>
{/* ── Avatar Upload ── */}
<AvatarUpload
avatarUrl={avatarUrl}
onAvatarChange={setAvatarUrl}
disabled={isLoading}
/>
{/* Fields */}
<div className="rg-fields">
<div className="rg-row">
<div className="rg-field">
<label className="rg-label" htmlFor="name">
Full Name
</label>
<div className="rg-input-wrap">
<input
id="name"
type="text"
className="rg-input"
style={{ paddingLeft: "1rem" }}
placeholder="Jane Doe"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
/>
</div>
</div>
</div>
<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}
/>
</div>
</div>
<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="Min. 6 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
{password && (
<>
<div className="rg-strength-bar">
{[1, 2, 3].map((seg) => (
<div
key={seg}
className={`rg-strength-seg ${strength >= seg ? S_CLASS[strength] : ""}`}
/>
))}
</div>
<p className={`rg-strength-hint ${S_CLASS[strength]}`}>
{S_LABEL[strength]} password
</p>
</>
)}
</div>
{error && (
<div className="rg-error">
<span></span> {error}
</div>
)}
<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-form-footer">
Already have an account?{" "}
<a href="/login" className="rg-link">
Sign in
</a>
</p>
</div>
</div>
</div>
);
};