web #1
@ -1,4 +1,126 @@
|
||||
import { Badge } from "./ui/badge";
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@600;700&display=swap');
|
||||
|
||||
.cc-btn {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border: 2.5px solid #f3f4f6;
|
||||
border-radius: 18px;
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.cc-btn:hover:not(.cc-selected) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.07);
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.cc-btn:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* Selected state */
|
||||
.cc-btn.cc-selected {
|
||||
border-color: #c4b5fd;
|
||||
background: #fdf4ff;
|
||||
box-shadow: 0 6px 0 #e9d5ff, 0 8px 20px rgba(168,85,247,0.1);
|
||||
}
|
||||
|
||||
/* Selected shimmer bar on left edge */
|
||||
.cc-btn.cc-selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, #a855f7, #7c3aed);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Top row */
|
||||
.cc-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cc-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 900;
|
||||
color: #1e1b4b;
|
||||
line-height: 1.2;
|
||||
flex: 1;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.cc-btn.cc-selected .cc-label { color: #7c3aed; }
|
||||
|
||||
/* Section badge */
|
||||
.cc-section-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 100px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.cc-section-badge.ebrw {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #2563eb;
|
||||
}
|
||||
.cc-section-badge.math {
|
||||
background: #fff1f2;
|
||||
border-color: #fecdd3;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
/* Sub label */
|
||||
.cc-sublabel {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
line-height: 1.3;
|
||||
padding-left: 0.05rem;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.cc-btn.cc-selected .cc-sublabel { color: #a855f7; }
|
||||
|
||||
/* Checkmark */
|
||||
.cc-check {
|
||||
position: absolute;
|
||||
top: 0.65rem;
|
||||
right: 0.75rem;
|
||||
width: 20px; height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e7eb;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||
background: white;
|
||||
}
|
||||
.cc-btn.cc-selected .cc-check {
|
||||
background: #a855f7;
|
||||
border-color: #a855f7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
`;
|
||||
|
||||
let stylesInjected = false;
|
||||
|
||||
export const ChoiceCard = ({
|
||||
label,
|
||||
@ -12,23 +134,51 @@ export const ChoiceCard = ({
|
||||
subLabel?: string;
|
||||
section?: string;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded-2xl border p-4 text-left transition flex flex-col
|
||||
${selected ? "border-indigo-600 bg-indigo-50" : "hover:border-gray-300"}`}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-satoshi-bold text-lg">{label}</span>
|
||||
{section && (
|
||||
<Badge
|
||||
variant={"secondary"}
|
||||
className={`font-satoshi text-sm ${section === "EBRW" ? "bg-blue-400 text-blue-100" : "bg-red-400 text-red-100"}`}
|
||||
>
|
||||
{section}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{subLabel && <span className="font-satoshi text-md">{subLabel}</span>}
|
||||
</button>
|
||||
);
|
||||
}) => {
|
||||
if (!stylesInjected) {
|
||||
const tag = document.createElement("style");
|
||||
tag.textContent = STYLES;
|
||||
document.head.appendChild(tag);
|
||||
stylesInjected = true;
|
||||
}
|
||||
|
||||
const sectionClass =
|
||||
section === "EBRW"
|
||||
? "ebrw"
|
||||
: section === "Math" || section === "MATH"
|
||||
? "math"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`cc-btn${selected ? " cc-selected" : ""}`}
|
||||
>
|
||||
{/* Checkmark */}
|
||||
<div className="cc-check">
|
||||
{selected && (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path
|
||||
d="M1.5 5L4 7.5L8.5 2.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top row: label + section badge */}
|
||||
<div className="cc-top" style={{ paddingRight: "1.75rem" }}>
|
||||
<span className="cc-label">{label}</span>
|
||||
{section && (
|
||||
<span className={`cc-section-badge ${sectionClass}`}>{section}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub label */}
|
||||
{subLabel && <span className="cc-sublabel">{subLabel}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card";
|
||||
import { api } from "../utils/api";
|
||||
import { useAuthToken } from "../hooks/useAuthToken";
|
||||
import {
|
||||
@ -36,34 +29,38 @@ interface PredictedScoreResponse {
|
||||
|
||||
const confidenceConfig: Record<
|
||||
string,
|
||||
{ label: string; color: string; bg: string; dot: string }
|
||||
{ label: string; color: string; bg: string; border: string; dot: string }
|
||||
> = {
|
||||
high: {
|
||||
label: "High confidence",
|
||||
color: "text-emerald-700",
|
||||
bg: "bg-emerald-50 border-emerald-200",
|
||||
dot: "bg-emerald-500",
|
||||
color: "#16a34a",
|
||||
bg: "#f0fdf4",
|
||||
border: "#bbf7d0",
|
||||
dot: "#22c55e",
|
||||
},
|
||||
medium: {
|
||||
label: "Medium confidence",
|
||||
color: "text-amber-700",
|
||||
bg: "bg-amber-50 border-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
color: "#d97706",
|
||||
bg: "#fffbeb",
|
||||
border: "#fde68a",
|
||||
dot: "#f59e0b",
|
||||
},
|
||||
low: {
|
||||
label: "Low confidence",
|
||||
color: "text-rose-700",
|
||||
bg: "bg-rose-50 border-rose-200",
|
||||
dot: "bg-rose-400",
|
||||
color: "#e11d48",
|
||||
bg: "#fff1f2",
|
||||
border: "#fecdd3",
|
||||
dot: "#f43f5e",
|
||||
},
|
||||
};
|
||||
|
||||
const getConfidenceStyle = (confidence: string) =>
|
||||
confidenceConfig[confidence.toLowerCase()] ?? {
|
||||
label: confidence,
|
||||
color: "text-gray-600",
|
||||
bg: "bg-gray-50 border-gray-200",
|
||||
dot: "bg-gray-400",
|
||||
color: "#6b7280",
|
||||
bg: "#f9fafb",
|
||||
border: "#f3f4f6",
|
||||
dot: "#9ca3af",
|
||||
};
|
||||
|
||||
const useCountUp = (target: number, duration = 900) => {
|
||||
@ -83,70 +80,256 @@ const useCountUp = (target: number, duration = 900) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
// ─── Expanded section detail ──────────────────────────────────────────────────
|
||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
.psc-card {
|
||||
background: white;
|
||||
border: 2.5px solid #f3f4f6;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
|
||||
font-family: 'Nunito', sans-serif;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.psc-header {
|
||||
padding: 1.1rem 1.25rem 0.75rem;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 2px solid #f9fafb;
|
||||
}
|
||||
.psc-header-left { display:flex;flex-direction:column;gap:0.15rem; }
|
||||
.psc-header-title {
|
||||
font-size: 0.88rem; font-weight: 900; color: #1e1b4b;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.psc-header-sub {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.7rem; font-weight: 600; color: #9ca3af;
|
||||
}
|
||||
.psc-header-icon {
|
||||
width: 36px; height: 36px; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 4px 0 #5b21b644;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.psc-body { padding: 1.1rem 1.25rem; display:flex;flex-direction:column;gap:0.85rem; }
|
||||
|
||||
/* Scores row */
|
||||
.psc-scores-row {
|
||||
display: flex; align-items: stretch; gap: 0;
|
||||
background: #fafaf9; border: 2px solid #f3f4f6;
|
||||
border-radius: 18px; overflow: hidden;
|
||||
}
|
||||
|
||||
.psc-score-cell {
|
||||
flex: 1; display:flex;flex-direction:column;align-items:center;
|
||||
padding: 1rem 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.psc-score-cell + .psc-score-cell::before {
|
||||
content:''; position:absolute; left:0; top:20%; bottom:20%;
|
||||
width:2px; background:#f3f4f6; border-radius:2px;
|
||||
}
|
||||
|
||||
/* Total cell — slightly different bg */
|
||||
.psc-score-cell.total {
|
||||
background: white;
|
||||
border-right: 2px solid #f3f4f6;
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
.psc-cell-label {
|
||||
display: flex; align-items: center; gap: 0.3rem;
|
||||
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: #9ca3af; margin-bottom: 0.3rem;
|
||||
}
|
||||
.psc-cell-score {
|
||||
font-weight: 900; color: #1e1b4b; line-height: 1;
|
||||
}
|
||||
.psc-cell-score.large { font-size: 2.8rem; }
|
||||
.psc-cell-score.medium { font-size: 1.7rem; }
|
||||
.psc-cell-out {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.62rem; font-weight: 600; color: #d1d5db;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* Toggle button */
|
||||
.psc-toggle-btn {
|
||||
width: 100%; display:flex;align-items:center;justify-content:center;gap:0.4rem;
|
||||
padding: 0.55rem; border-radius: 12px; border: 2px solid #f3f4f6;
|
||||
background: white; cursor: pointer;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.72rem; font-weight: 800; color: #9ca3af;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.psc-toggle-btn:hover { border-color: #e9d5ff; color: #a855f7; background: #fdf4ff; }
|
||||
|
||||
/* Section detail cards */
|
||||
.psc-detail-card {
|
||||
background: #fafaf9; border: 2.5px solid #f3f4f6; border-radius: 18px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex; flex-direction: column; gap: 0.65rem;
|
||||
}
|
||||
|
||||
.psc-detail-top {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||
}
|
||||
.psc-detail-icon-wrap {
|
||||
width: 30px; height: 30px; border-radius: 10px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.psc-detail-label {
|
||||
font-size: 0.8rem; font-weight: 900; color: #1e1b4b; flex: 1;
|
||||
}
|
||||
.psc-conf-badge {
|
||||
display: flex; align-items: center; gap: 0.3rem;
|
||||
padding: 0.2rem 0.6rem; border-radius: 100px; border: 2px solid;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.6rem; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.psc-conf-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0; }
|
||||
|
||||
.psc-score-range-row {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
}
|
||||
.psc-detail-score {
|
||||
font-size: 1.6rem; font-weight: 900; color: #1e1b4b; line-height: 1;
|
||||
}
|
||||
.psc-range-text {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.68rem; font-weight: 600; color: #9ca3af;
|
||||
text-align: right; line-height: 1.4;
|
||||
}
|
||||
.psc-range-text span { font-weight: 800; color: #6b7280; }
|
||||
|
||||
/* Range bar */
|
||||
.psc-bar-wrap {
|
||||
height: 8px; border-radius: 100px; background: #f3f4f6;
|
||||
position: relative; overflow: visible;
|
||||
}
|
||||
.psc-bar-fill {
|
||||
position: absolute; height: 100%; border-radius: 100px; opacity: 0.4;
|
||||
}
|
||||
.psc-bar-dot {
|
||||
position: absolute; width: 14px; height: 14px;
|
||||
border-radius: 50%; border: 2.5px solid white;
|
||||
top: 50%; transform: translate(-50%, -50%);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
|
||||
}
|
||||
.psc-bar-labels {
|
||||
display: flex; justify-content: space-between;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.58rem; font-weight: 600; color: #d1d5db;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* Expanded animation */
|
||||
.psc-expanded-wrap {
|
||||
display: flex; flex-direction: column; gap: 0.6rem;
|
||||
animation: pscFadeIn 0.3s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
@keyframes pscFadeIn {
|
||||
from { opacity:0; transform:translateY(-8px); }
|
||||
to { opacity:1; transform:translateY(0); }
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.psc-loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 0.5rem; padding: 2rem;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.82rem; font-weight: 600; color: #9ca3af;
|
||||
}
|
||||
.psc-spinner { animation: pscSpin 0.8s linear infinite; }
|
||||
@keyframes pscSpin { to { transform: rotate(360deg); } }
|
||||
|
||||
.psc-error {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.82rem; font-weight: 700; color: #e11d48;
|
||||
text-align: center; padding: 1.5rem;
|
||||
background: #fff1f2; border-radius: 14px; border: 2px solid #fecdd3;
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Section detail ───────────────────────────────────────────────────────────
|
||||
|
||||
const SectionDetail = ({
|
||||
label,
|
||||
icon: Icon,
|
||||
prediction,
|
||||
accentClass,
|
||||
iconBg,
|
||||
barColor,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
prediction: SectionPrediction;
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
barColor: string;
|
||||
}) => {
|
||||
const conf = getConfidenceStyle(prediction.confidence);
|
||||
const pct = (v: number) => ((v - 200) / (800 - 200)) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-1.5 rounded-lg ${accentClass}`}>
|
||||
<Icon size={14} className="text-white" />
|
||||
</div>
|
||||
<span className="font-satoshi-medium text-sm text-gray-700">
|
||||
{label}
|
||||
</span>
|
||||
<div className="psc-detail-card">
|
||||
<div className="psc-detail-top">
|
||||
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
|
||||
<Icon size={15} color={barColor} />
|
||||
</div>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full border font-satoshi ${conf.bg} ${conf.color}`}
|
||||
<span className="psc-detail-label">{label}</span>
|
||||
<div
|
||||
className="psc-conf-badge"
|
||||
style={{
|
||||
background: conf.bg,
|
||||
borderColor: conf.border,
|
||||
color: conf.color,
|
||||
}}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${conf.dot}`} />
|
||||
<div className="psc-conf-dot" style={{ background: conf.dot }} />
|
||||
{conf.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between mt-1">
|
||||
<span className="font-satoshi-bold text-2xl text-gray-900">
|
||||
{prediction.score}
|
||||
</span>
|
||||
<span className="font-satoshi text-xs text-gray-400 mb-1">
|
||||
Range:{" "}
|
||||
<span className="text-gray-600 font-satoshi-medium">
|
||||
<div className="psc-score-range-row">
|
||||
<span className="psc-detail-score">{prediction.score}</span>
|
||||
<div className="psc-range-text">
|
||||
<span>Range</span>
|
||||
<br />
|
||||
<span>
|
||||
{prediction.range_min}–{prediction.range_max}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Range bar */}
|
||||
<div className="relative h-1.5 rounded-full bg-gray-200 mt-1">
|
||||
<div
|
||||
className={`absolute h-1.5 rounded-full ${accentClass} opacity-60`}
|
||||
style={{
|
||||
left: `${((prediction.range_min - 200) / (800 - 200)) * 100}%`,
|
||||
right: `${100 - ((prediction.range_max - 200) / (800 - 200)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute w-2.5 h-2.5 rounded-full border-2 border-white ${accentClass} -top-0.5 shadow-sm`}
|
||||
style={{
|
||||
left: `calc(${((prediction.score - 200) / (800 - 200)) * 100}% - 5px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-300 font-satoshi mt-0.5">
|
||||
<span>200</span>
|
||||
<span>800</span>
|
||||
<div>
|
||||
<div className="psc-bar-wrap">
|
||||
<div
|
||||
className="psc-bar-fill"
|
||||
style={{
|
||||
left: `${pct(prediction.range_min)}%`,
|
||||
right: `${100 - pct(prediction.range_max)}%`,
|
||||
background: barColor,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="psc-bar-dot"
|
||||
style={{
|
||||
left: `${pct(prediction.score)}%`,
|
||||
background: barColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="psc-bar-labels">
|
||||
<span>200</span>
|
||||
<span>800</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -154,6 +337,8 @@ const SectionDetail = ({
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
let stylesInjected = false;
|
||||
|
||||
export const PredictedScoreCard = () => {
|
||||
const token = useAuthToken();
|
||||
const [data, setData] = useState<PredictedScoreResponse | null>(null);
|
||||
@ -177,129 +362,113 @@ export const PredictedScoreCard = () => {
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
if (!stylesInjected) {
|
||||
const tag = document.createElement("style");
|
||||
tag.textContent = STYLES;
|
||||
document.head.appendChild(tag);
|
||||
stylesInjected = true;
|
||||
}
|
||||
|
||||
const animatedTotal = useCountUp(data?.total_score ?? 0, 1000);
|
||||
|
||||
return (
|
||||
<Card className="w-full border border-gray-200 shadow-sm overflow-hidden">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="font-satoshi-bold text-lg text-gray-900">
|
||||
Predicted SAT Score
|
||||
</CardTitle>
|
||||
<CardDescription className="font-satoshi text-sm text-gray-400 mt-0.5">
|
||||
Based on your practice performance
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="p-2 rounded-xl bg-purple-50 border border-purple-100">
|
||||
<TrendingUp size={18} className="text-purple-500" />
|
||||
</div>
|
||||
<div className="psc-card">
|
||||
{/* Header */}
|
||||
<div className="psc-header">
|
||||
<div className="psc-header-left">
|
||||
<p className="psc-header-title">Predicted SAT Score</p>
|
||||
<p className="psc-header-sub">Based on your practice performance</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="psc-header-icon">
|
||||
<TrendingUp size={17} color="white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Body */}
|
||||
<div className="psc-body">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={26} className="animate-spin text-purple-400" />
|
||||
<div className="psc-loading">
|
||||
<Loader2 size={20} color="#a855f7" className="psc-spinner" />
|
||||
Calculating your score...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<p className="font-satoshi text-sm text-rose-500 text-center py-4">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{error && !loading && <div className="psc-error">⚠️ {error}</div>}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
{/* ── Collapsed view: big numbers only ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Score cells */}
|
||||
<div className="psc-scores-row">
|
||||
{/* Total */}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-satoshi text-lg text-gray-400 mb-0.5">
|
||||
Total
|
||||
</span>
|
||||
<span className="font-satoshi-bold text-6xl text-gray-900 leading-none">
|
||||
{animatedTotal}
|
||||
</span>
|
||||
<span className="font-satoshi text-[18px] text-gray-300 mt-1">
|
||||
out of 1600
|
||||
</span>
|
||||
<div className="psc-score-cell total">
|
||||
<div className="psc-cell-label">
|
||||
<TrendingUp size={10} color="#a855f7" /> Total
|
||||
</div>
|
||||
<span className="psc-cell-score large">{animatedTotal}</span>
|
||||
<span className="psc-cell-out">/ 1600</span>
|
||||
</div>
|
||||
|
||||
<div className="h-12 w-px bg-gray-100" />
|
||||
|
||||
{/* Math */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<Calculator size={16} className="text-violet-400" />
|
||||
<span className="font-satoshi text-sm text-gray-400">
|
||||
Math
|
||||
</span>
|
||||
<div className="psc-score-cell">
|
||||
<div className="psc-cell-label">
|
||||
<Calculator size={10} color="#7c3aed" /> Math
|
||||
</div>
|
||||
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
|
||||
<span className="psc-cell-score medium">
|
||||
{data.math_prediction.score}
|
||||
</span>
|
||||
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
|
||||
out of 800
|
||||
</span>
|
||||
<span className="psc-cell-out">/ 800</span>
|
||||
</div>
|
||||
|
||||
<div className="h-12 w-px bg-gray-100" />
|
||||
|
||||
{/* R&W */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<BookOpen size={16} className="text-sky-400" />
|
||||
<span className="font-satoshi text-sm text-gray-400">
|
||||
R&W
|
||||
</span>
|
||||
<div className="psc-score-cell">
|
||||
<div className="psc-cell-label">
|
||||
<BookOpen size={10} color="#0891b2" /> R&W
|
||||
</div>
|
||||
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
|
||||
<span className="psc-cell-score medium">
|
||||
{data.rw_prediction.score}
|
||||
</span>
|
||||
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
|
||||
out of 800
|
||||
</span>
|
||||
<span className="psc-cell-out">/ 800</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Expand toggle ── */}
|
||||
{/* Toggle */}
|
||||
<button
|
||||
className="psc-toggle-btn"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs font-satoshi-medium text-gray-400 hover:text-purple-500 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp size={14} /> Less detail
|
||||
<ChevronUp size={13} /> Less detail
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={14} /> More detail
|
||||
<ChevronDown size={13} /> Score breakdown
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* ── Expanded: range bars + confidence ── */}
|
||||
{/* Expanded */}
|
||||
{expanded && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="psc-expanded-wrap">
|
||||
<SectionDetail
|
||||
label="Math"
|
||||
label="Mathematics"
|
||||
icon={Calculator}
|
||||
prediction={data.math_prediction}
|
||||
accentClass="bg-violet-500"
|
||||
iconBg="#fdf4ff"
|
||||
barColor="#a855f7"
|
||||
/>
|
||||
<SectionDetail
|
||||
label="Reading & Writing"
|
||||
icon={BookOpen}
|
||||
prediction={data.rw_prediction}
|
||||
accentClass="bg-sky-500"
|
||||
iconBg="#ecfeff"
|
||||
barColor="#0891b2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
7
src/hooks/usePageTitle.ts
Normal file
7
src/hooks/usePageTitle.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function usePageTitle(title: string) {
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
}, [title]);
|
||||
}
|
||||
@ -2,16 +2,185 @@ import { useState, useEffect } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { Loader2, Mail, Lock } from "lucide-react";
|
||||
import { usePageTitle } from "../../hooks/usePageTitle";
|
||||
|
||||
interface LocationState {
|
||||
from?: {
|
||||
pathname: string;
|
||||
};
|
||||
from?: { pathname: string };
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
.lg-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 */
|
||||
.lg-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(52px);opacity:0.38; }
|
||||
.lg-blob-1 { width:280px;height:280px;background:#fde68a;top:-100px;left:-100px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:lgWobble1 14s ease-in-out infinite; }
|
||||
.lg-blob-2 { width:220px;height:220px;background:#a5f3c0;bottom:-60px;left:4%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:lgWobble2 16s ease-in-out infinite; }
|
||||
.lg-blob-3 { width:250px;height:250px;background:#fbcfe8;top:10%;right:-70px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:lgWobble1 18s ease-in-out infinite reverse; }
|
||||
.lg-blob-4 { width:180px;height:180px;background:#bfdbfe;bottom:8%;right:0;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:lgWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes lgWobble1 {
|
||||
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 lgWobble2 {
|
||||
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);}
|
||||
}
|
||||
|
||||
.lg-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.28;animation:lgFloat 7s ease-in-out infinite; }
|
||||
@keyframes lgFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-14px) rotate(180deg);}
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.lg-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: lgPopIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
@keyframes lgPopIn {
|
||||
from { opacity:0; transform:scale(0.9) translateY(20px); }
|
||||
to { opacity:1; transform:scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* Logo area */
|
||||
.lg-logo-wrap {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.85rem;
|
||||
}
|
||||
.lg-logo-badge {
|
||||
width: 64px; height: 64px; border-radius: 20px;
|
||||
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 6px 0 #5b21b655, 0 10px 24px rgba(124,58,237,0.25);
|
||||
font-size: 1.75rem;
|
||||
animation: lgPopIn 0.5s cubic-bezier(0.34,1.56,0.64,1) 0.1s both;
|
||||
}
|
||||
.lg-title {
|
||||
font-size: 1.5rem; font-weight: 900; color: #1e1b4b;
|
||||
letter-spacing: -0.02em; text-align: center;
|
||||
}
|
||||
.lg-sub {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.82rem; font-weight: 600; color: #9ca3af;
|
||||
text-align: center; margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
.lg-fields { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
.lg-field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.lg-label {
|
||||
font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; color: #6b7280;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
.lg-input-wrap { position: relative; }
|
||||
.lg-input-icon {
|
||||
position: absolute; left: 0.85rem; top: 50%;
|
||||
transform: translateY(-50%); pointer-events: none; color: #9ca3af;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.lg-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;
|
||||
}
|
||||
.lg-input:focus {
|
||||
background: white; border-color: #c4b5fd;
|
||||
box-shadow: 0 0 0 3px rgba(168,85,247,0.1);
|
||||
}
|
||||
.lg-input:focus ~ .lg-input-icon { color: #a855f7; }
|
||||
.lg-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.lg-input::placeholder { color: #d1d5db; }
|
||||
|
||||
/* Remember me */
|
||||
.lg-remember {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
.lg-checkbox {
|
||||
width: 18px; height: 18px; border-radius: 6px;
|
||||
accent-color: #a855f7; cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.lg-remember-label {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.8rem; font-weight: 600; color: #6b7280;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.lg-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 */
|
||||
.lg-btn {
|
||||
width: 100%; padding: 0.95rem;
|
||||
background: #f97316; 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 #c2560e, 0 8px 20px rgba(249,115,22,0.25);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.lg-btn:hover { transform:translateY(-2px); box-shadow:0 8px 0 #c2560e,0 12px 24px rgba(249,115,22,0.3); }
|
||||
.lg-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #c2560e; }
|
||||
.lg-btn:disabled {
|
||||
background: #e5e7eb; color: #9ca3af;
|
||||
cursor: not-allowed; box-shadow: 0 4px 0 #d1d5db;
|
||||
}
|
||||
.lg-btn:disabled:hover { transform: none; box-shadow: 0 4px 0 #d1d5db; }
|
||||
|
||||
.lg-spinner { animation: lgSpin 0.8s linear infinite; }
|
||||
@keyframes lgSpin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Footer hint */
|
||||
.lg-footer {
|
||||
text-align: center;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.75rem; font-weight: 600; color: #9ca3af;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Login = () => {
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@ -20,14 +189,10 @@ export const Login = () => {
|
||||
|
||||
const from = (location.state as LocationState)?.from?.pathname || "/student";
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate("/student/home", { replace: true });
|
||||
}
|
||||
if (isAuthenticated) navigate("/student/home", { replace: true });
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
// Clear error when component unmounts or inputs change
|
||||
useEffect(() => {
|
||||
return () => clearError();
|
||||
}, [clearError]);
|
||||
@ -35,122 +200,140 @@ export const Login = () => {
|
||||
const handleSubmit = async (e: FormEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
const success = await login({ email, password });
|
||||
if (success) {
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
if (success) navigate(from, { replace: true });
|
||||
};
|
||||
|
||||
// Don't render login form if already authenticated
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
if (isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center ">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-sm border border-gray-300 space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src="src/assets/ed_logo.png"
|
||||
alt="EdBridge logo"
|
||||
className="h-15 w-auto object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-satoshi-bold text-center text-gray-800">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-satoshi-medium text-gray-700 mb-2"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder="Enter your email"
|
||||
<div className="lg-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="lg-blob lg-blob-1" />
|
||||
<div className="lg-blob lg-blob-2" />
|
||||
<div className="lg-blob lg-blob-3" />
|
||||
<div className="lg-blob lg-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="lg-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="lg-card">
|
||||
{/* Logo + heading */}
|
||||
<div className="lg-logo-wrap">
|
||||
<div className="lg-logo-badge">
|
||||
<img
|
||||
src="src/assets/ed_logo.png"
|
||||
alt="EdBridge"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-satoshi-medium text-gray-700 mb-2"
|
||||
>
|
||||
Password
|
||||
<h1 className="lg-title">Welcome back 👋</h1>
|
||||
<p className="lg-sub">Sign in to continue your SAT prep</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="lg-fields">
|
||||
{/* Email */}
|
||||
<div className="lg-field">
|
||||
<label className="lg-label" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="flex items-center mt-4">
|
||||
<div className="lg-input-wrap">
|
||||
<Mail size={15} className="lg-input-icon" />
|
||||
<input
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
id="email"
|
||||
type="email"
|
||||
className="lg-input"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="ml-2 block text-sm font-satoshi-medium text-gray-700"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="lg-field">
|
||||
<label className="lg-label" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<div className="lg-input-wrap">
|
||||
<Lock size={15} className="lg-input-icon" />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="lg-input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remember me */}
|
||||
<div className="lg-remember">
|
||||
<input id="rememberMe" type="checkbox" className="lg-checkbox" />
|
||||
<label htmlFor="rememberMe" className="lg-remember-label">
|
||||
Keep me signed in
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
<div className="lg-error">
|
||||
<span>⚠️</span> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
className="lg-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !email || !password}
|
||||
className="w-full bg-linear-to-br from-indigo-500 to-indigo-600 text-white py-3 rounded-2xl hover:bg-indigo-700 transition font-medium disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center font-satoshi hover:cursor-pointer"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
<Loader2 size={18} className="lg-spinner" /> Signing in...
|
||||
</>
|
||||
) : (
|
||||
"Sign In"
|
||||
"Sign In →"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="lg-footer">
|
||||
By signing in you agree to Edbridge's Terms & Privacy Policy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -36,7 +36,7 @@ const STYLES = `
|
||||
.home-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Satoshi', sans-serif;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@ -248,6 +248,30 @@ const STYLES = `
|
||||
.h-tip-icon { flex-shrink:0;margin-top:1px; }
|
||||
.h-tip-text { font-size:0.85rem;font-weight:700;color:#374151;line-height:1.4; }
|
||||
|
||||
|
||||
/* ── Load more ── */
|
||||
.h-load-more-btn {
|
||||
width: 100%; margin-top: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: white; border: 2.5px solid #f3f4f6;
|
||||
border-radius: 100px; cursor: pointer;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.82rem; font-weight: 800; color: #9ca3af;
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.4rem;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.h-load-more-btn:hover { border-color: #c4b5fd; color: #a855f7; background: #fdf4ff; transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.06); }
|
||||
.h-load-more-btn:active { transform: translateY(1px); }
|
||||
|
||||
.h-sheet-count {
|
||||
text-align: center;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.72rem; font-weight: 600; color: #d1d5db;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.h-sheet-count span { font-weight: 800; color: #9ca3af; }
|
||||
|
||||
@keyframes hPopIn {
|
||||
from{opacity:0;transform:scale(0.92) translateY(10px);}
|
||||
to{opacity:1;transform:scale(1) translateY(0);}
|
||||
@ -305,6 +329,8 @@ const TIPS = [
|
||||
];
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 2;
|
||||
|
||||
export const Home = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const navigate = useNavigate();
|
||||
@ -319,6 +345,7 @@ export const Home = () => {
|
||||
>("all");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
const sort = (sheets: PracticeSheet[]) => {
|
||||
@ -352,13 +379,22 @@ export const Home = () => {
|
||||
|
||||
const handleStart = (id: string) => navigate(`/student/practice/${id}`);
|
||||
|
||||
const tabSheets =
|
||||
const allTabSheets =
|
||||
activeTab === "all"
|
||||
? practiceSheets
|
||||
: activeTab === "NOT_STARTED"
|
||||
? notStartedSheets
|
||||
: completedSheets;
|
||||
|
||||
const tabSheets = allTabSheets.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < allTabSheets.length;
|
||||
const remaining = allTabSheets.length - visibleCount;
|
||||
|
||||
const handleTabChange = (tab: "all" | "NOT_STARTED" | "COMPLETED") => {
|
||||
setActiveTab(tab);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
};
|
||||
|
||||
const greeting =
|
||||
new Date().getHours() < 12
|
||||
? "Good morning"
|
||||
@ -414,9 +450,9 @@ export const Home = () => {
|
||||
{user?.name?.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<p className="home-user-name">
|
||||
{greeting}, {user?.name?.split(" ")[0] || "Student"}
|
||||
{greeting}, {user?.name?.split(" ")[0] || "Student"} 👋
|
||||
</p>
|
||||
<p className="home-user-role">
|
||||
{user?.role === "STUDENT"
|
||||
@ -513,7 +549,7 @@ export const Home = () => {
|
||||
<button
|
||||
key={tab}
|
||||
className={`h-tab-btn${activeTab === tab ? " active" : ""}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
onClick={() => handleTabChange(tab)}
|
||||
>
|
||||
{tab === "all"
|
||||
? "All"
|
||||
@ -524,12 +560,30 @@ export const Home = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tabSheets.length > 0 ? (
|
||||
<div className="h-sheet-grid">
|
||||
{tabSheets.map((sheet) => (
|
||||
<SheetCard key={sheet.id} sheet={sheet} onStart={handleStart} />
|
||||
))}
|
||||
</div>
|
||||
{allTabSheets.length > 0 ? (
|
||||
<>
|
||||
<div className="h-sheet-grid">
|
||||
{tabSheets.map((sheet) => (
|
||||
<SheetCard
|
||||
key={sheet.id}
|
||||
sheet={sheet}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<button
|
||||
className="h-load-more-btn"
|
||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
||||
>
|
||||
↓ Show {Math.min(remaining, PAGE_SIZE)} more
|
||||
</button>
|
||||
) : allTabSheets.length > PAGE_SIZE ? (
|
||||
<p className="h-sheet-count">
|
||||
Showing all <span>{allTabSheets.length}</span> sheets
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-empty">
|
||||
<span className="h-empty-emoji">🔍</span>
|
||||
|
||||
@ -279,7 +279,6 @@ export const Practice = () => {
|
||||
<BookOpen size={20} color="white" />
|
||||
</div>
|
||||
<div className="pr-xp-chip">
|
||||
<div className="pr-xp-dot" />
|
||||
<span>⚡ {userXp} XP</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -5,119 +5,436 @@ import { api } from "../../../utils/api";
|
||||
import { ChoiceCard } from "../../../components/ChoiceCard";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { slideVariants } from "../../../lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Search, Zap } from "lucide-react";
|
||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type Step = "topic" | "review";
|
||||
const STEPS: Step[] = ["topic", "review"];
|
||||
|
||||
const DOTS = [
|
||||
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
|
||||
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
|
||||
{ size: 9, color: "#22c55e", top: "62%", left: "3%", delay: "0.6s" },
|
||||
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
|
||||
{ size: 7, color: "#f43f5e", top: "48%", right: "2%", delay: "0.9s" },
|
||||
{ size: 9, color: "#eab308", top: "76%", right: "5%", delay: "0.4s" },
|
||||
];
|
||||
|
||||
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');
|
||||
|
||||
.dr-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.dr-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
|
||||
.dr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:drWobble1 14s ease-in-out infinite; }
|
||||
.dr-blob-2 { width:190px;height:190px;background:#a5f3fc;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:drWobble2 16s ease-in-out infinite; }
|
||||
.dr-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:drWobble1 18s ease-in-out infinite reverse; }
|
||||
.dr-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:drWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes drWobble1 {
|
||||
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(12px,16px) rotate(8deg);}
|
||||
}
|
||||
@keyframes drWobble2 {
|
||||
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(-10px,12px) rotate(-6deg);}
|
||||
}
|
||||
|
||||
.dr-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:drFloat 7s ease-in-out infinite; }
|
||||
@keyframes drFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-12px) rotate(180deg);}
|
||||
}
|
||||
|
||||
.dr-inner {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 560px; margin: 0 auto;
|
||||
padding: 2rem 1.25rem 8rem;
|
||||
display: flex; flex-direction: column; gap: 1.5rem;
|
||||
}
|
||||
|
||||
@keyframes drPopIn {
|
||||
from { opacity:0; transform:scale(0.92) translateY(12px); }
|
||||
to { opacity:1; transform:scale(1) translateY(0); }
|
||||
}
|
||||
.dr-anim { animation: drPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.dr-anim-1 { animation-delay:0.05s; }
|
||||
.dr-anim-2 { animation-delay:0.1s; }
|
||||
|
||||
/* Header */
|
||||
.dr-header-row { display:flex;align-items:center;gap:0.75rem; }
|
||||
.dr-back-btn {
|
||||
width:40px;height:40px;border-radius:50%;
|
||||
background:white;border:2.5px solid #f3f4f6;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;box-shadow:0 3px 10px rgba(0,0,0,0.05);
|
||||
transition:all 0.15s ease;flex-shrink:0;
|
||||
}
|
||||
.dr-back-btn:hover { border-color:#a5f3fc;background:#ecfeff; }
|
||||
.dr-back-btn:active { transform:scale(0.9); }
|
||||
.dr-back-btn.hidden { opacity:0;pointer-events:none; }
|
||||
|
||||
.dr-eyebrow {
|
||||
font-size:0.62rem;font-weight:800;letter-spacing:0.16em;
|
||||
text-transform:uppercase;color:#0891b2;
|
||||
display:flex;align-items:center;gap:0.35rem;
|
||||
}
|
||||
.dr-title {
|
||||
font-size:1.75rem;font-weight:900;color:#1e1b4b;
|
||||
letter-spacing:-0.02em;line-height:1.15;
|
||||
}
|
||||
.dr-sub {
|
||||
font-family:'Nunito Sans',sans-serif;
|
||||
font-size:0.82rem;font-weight:600;color:#9ca3af;
|
||||
margin-top:0.2rem;line-height:1.5;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.dr-progress-wrap {
|
||||
background:white;border:2.5px solid #f3f4f6;
|
||||
border-radius:100px;overflow:hidden;height:8px;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.dr-progress-fill {
|
||||
height:100%;
|
||||
background:linear-gradient(90deg,#22d3ee,#0891b2);
|
||||
border-radius:100px;
|
||||
transition:width 0.5s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
.dr-progress-labels {
|
||||
display:flex;justify-content:space-between;
|
||||
font-size:0.6rem;font-weight:800;letter-spacing:0.1em;
|
||||
text-transform:uppercase;color:#d1d5db;
|
||||
margin-top:0.35rem;padding:0 0.1rem;
|
||||
}
|
||||
.dr-progress-labels span.done { color:#0891b2; }
|
||||
|
||||
/* Step card */
|
||||
.dr-step-card {
|
||||
background:white;border:2.5px solid #f3f4f6;
|
||||
border-radius:24px;padding:1.25rem;
|
||||
box-shadow:0 4px 20px rgba(0,0,0,0.05);
|
||||
display:flex;flex-direction:column;gap:1rem;
|
||||
}
|
||||
.dr-step-title {
|
||||
font-size:1rem;font-weight:900;color:#1e1b4b;
|
||||
display:flex;align-items:center;gap:0.5rem;
|
||||
}
|
||||
.dr-step-badge {
|
||||
font-size:0.58rem;font-weight:800;letter-spacing:0.1em;
|
||||
text-transform:uppercase;padding:0.2rem 0.55rem;
|
||||
border-radius:100px;background:#ecfeff;
|
||||
border:2px solid #a5f3fc;color:#0891b2;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.dr-search-wrap { position:relative; }
|
||||
.dr-search-icon {
|
||||
position:absolute;left:0.85rem;top:50%;
|
||||
transform:translateY(-50%);pointer-events:none;color:#9ca3af;
|
||||
}
|
||||
.dr-search-input {
|
||||
width:100%;padding:0.7rem 1rem 0.7rem 2.5rem;
|
||||
background:#f9fafb;border:2.5px solid #f3f4f6;
|
||||
border-radius:14px;
|
||||
font-family:'Nunito Sans',sans-serif;
|
||||
font-size:0.85rem;font-weight:600;color:#1e1b4b;
|
||||
outline:none;transition:all 0.2s ease;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
.dr-search-input:focus {
|
||||
border-color:#67e8f9;background:white;
|
||||
box-shadow:0 0 0 3px rgba(8,145,178,0.1);
|
||||
}
|
||||
.dr-search-input::placeholder { color:#9ca3af; }
|
||||
|
||||
/* Topic grid */
|
||||
.dr-topic-grid {
|
||||
display:grid;grid-template-columns:1fr;
|
||||
gap:0.6rem;max-height:380px;overflow-y:auto;
|
||||
padding-right:0.25rem;
|
||||
}
|
||||
@media(min-width:480px){ .dr-topic-grid { grid-template-columns:1fr 1fr; } }
|
||||
|
||||
/* Loading / empty */
|
||||
.dr-loading {
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
gap:0.6rem;padding:2rem;
|
||||
font-size:0.85rem;font-weight:700;color:#9ca3af;
|
||||
}
|
||||
.dr-spinner { animation:drSpin 0.8s linear infinite; }
|
||||
@keyframes drSpin { to { transform:rotate(360deg); } }
|
||||
.dr-empty { text-align:center;padding:2rem;color:#9ca3af;font-size:0.85rem;font-weight:700; }
|
||||
|
||||
/* Review rows */
|
||||
.dr-review-row {
|
||||
display:flex;align-items:flex-start;gap:0.75rem;
|
||||
padding:0.85rem 0;border-bottom:2px solid #f9fafb;
|
||||
}
|
||||
.dr-review-row:last-child { border-bottom:none;padding-bottom:0; }
|
||||
.dr-review-icon {
|
||||
width:34px;height:34px;border-radius:10px;flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center;font-size:0.95rem;
|
||||
}
|
||||
.dr-review-label {
|
||||
font-size:0.62rem;font-weight:800;letter-spacing:0.12em;
|
||||
text-transform:uppercase;color:#9ca3af;
|
||||
}
|
||||
.dr-review-value {
|
||||
font-size:0.9rem;font-weight:800;color:#1e1b4b;
|
||||
margin-top:0.1rem;line-height:1.4;
|
||||
}
|
||||
.dr-chip-wrap { display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.35rem; }
|
||||
.dr-chip {
|
||||
background:#ecfeff;border:2px solid #a5f3fc;
|
||||
border-radius:100px;padding:0.2rem 0.65rem;
|
||||
font-size:0.72rem;font-weight:800;color:#0891b2;
|
||||
}
|
||||
|
||||
/* Stat chips in review */
|
||||
.dr-stat-row { display:flex;gap:0.6rem;margin-top:0.25rem; }
|
||||
.dr-stat {
|
||||
display:flex;flex-direction:column;align-items:center;
|
||||
background:#f0fdff;border:2px solid #a5f3fc;
|
||||
border-radius:14px;padding:0.5rem 0.85rem;flex:1;
|
||||
}
|
||||
.dr-stat-val { font-size:1rem;font-weight:900;color:#0891b2; }
|
||||
.dr-stat-label {
|
||||
font-size:0.58rem;font-weight:800;letter-spacing:0.1em;
|
||||
text-transform:uppercase;color:#67e8f9;margin-top:0.1rem;
|
||||
}
|
||||
|
||||
/* CTA bar */
|
||||
.dr-cta-bar {
|
||||
position:fixed;bottom:96px;left:0;right:0;z-index:10;
|
||||
padding:0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
|
||||
|
||||
|
||||
}
|
||||
.dr-cta-inner {
|
||||
max-width:560px;margin:0 auto;
|
||||
display:flex;gap:0.75rem;align-items:center;
|
||||
}
|
||||
|
||||
.dr-next-btn {
|
||||
flex:1;padding:0.9rem 1.5rem;
|
||||
background:#0891b2;color:white;border:none;
|
||||
border-radius:100px;cursor:pointer;
|
||||
font-family:'Nunito',sans-serif;font-size:0.92rem;font-weight:900;
|
||||
display:flex;align-items:center;justify-content:center;gap:0.4rem;
|
||||
box-shadow:0 6px 0 #0e7490,0 8px 20px rgba(8,145,178,0.28);
|
||||
transition:transform 0.1s ease,box-shadow 0.1s ease;
|
||||
}
|
||||
.dr-next-btn:hover { transform:translateY(-2px);box-shadow:0 8px 0 #0e7490,0 12px 24px rgba(8,145,178,0.32); }
|
||||
.dr-next-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #0e7490; }
|
||||
.dr-next-btn:disabled {
|
||||
background:#e5e7eb;color:#9ca3af;cursor:not-allowed;
|
||||
box-shadow:0 4px 0 #d1d5db;
|
||||
}
|
||||
.dr-next-btn:disabled:hover { transform:none;box-shadow:0 4px 0 #d1d5db; }
|
||||
|
||||
.dr-start-btn {
|
||||
flex:1;padding:0.9rem 1.5rem;
|
||||
background:linear-gradient(135deg,#22d3ee,#0891b2);color:white;border:none;
|
||||
border-radius:100px;cursor:pointer;
|
||||
font-family:'Nunito',sans-serif;font-size:0.92rem;font-weight:900;
|
||||
display:flex;align-items:center;justify-content:center;gap:0.4rem;
|
||||
box-shadow:0 6px 0 #0e7490,0 8px 20px rgba(8,145,178,0.3);
|
||||
transition:transform 0.1s ease,box-shadow 0.1s ease;
|
||||
}
|
||||
.dr-start-btn:hover { transform:translateY(-2px);box-shadow:0 8px 0 #0e7490,0 12px 24px rgba(8,145,178,0.35); }
|
||||
.dr-start-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #0e7490; }
|
||||
`;
|
||||
|
||||
export const Drills = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [step, setStep] = useState<Step>("topic");
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { storeTopics, setMode, setQuestionCount } = useExamConfigStore();
|
||||
|
||||
const stepIndex = STEPS.indexOf(step);
|
||||
const progressPct = ((stepIndex + 1) / STEPS.length) * 100;
|
||||
|
||||
const toggleTopic = (topic: Topic) => {
|
||||
setSelectedTopics((prev) => {
|
||||
const exists = prev.some((t) => t.id === topic.id);
|
||||
setSelectedTopics((prev) =>
|
||||
prev.some((t) => t.id === topic.id)
|
||||
? prev.filter((t) => t.id !== topic.id)
|
||||
: [...prev, topic],
|
||||
);
|
||||
};
|
||||
|
||||
if (exists) {
|
||||
return prev.filter((t) => t.id !== topic.id);
|
||||
}
|
||||
|
||||
return [...prev, topic];
|
||||
});
|
||||
const goNext = () => {
|
||||
setDirection(1);
|
||||
setStep("review");
|
||||
};
|
||||
const goBack = () => {
|
||||
setDirection(-1);
|
||||
setStep("topic");
|
||||
};
|
||||
|
||||
function handleStartDrill() {
|
||||
if (!user || !topics) return;
|
||||
|
||||
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllTopics = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const authStorage = localStorage.getItem("auth-storage");
|
||||
if (!authStorage) return;
|
||||
|
||||
const parsed = JSON.parse(authStorage) as {
|
||||
state?: { token?: string };
|
||||
};
|
||||
|
||||
const token = parsed.state?.token;
|
||||
const {
|
||||
state: { token },
|
||||
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||
if (!token) return;
|
||||
|
||||
const response = await api.fetchAllTopics(token);
|
||||
setTopics(response);
|
||||
} catch (e) {
|
||||
console.error("Failed to load topics:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to load topics. Reason: " + error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllTopics();
|
||||
}, [user]);
|
||||
|
||||
const filteredTopics = topics.filter((t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
||||
<header className="space-y-2">
|
||||
<h1 className="font-satoshi-bold text-3xl">Drills</h1>
|
||||
<p className="font-satoshi text-md text-gray-500">
|
||||
Train your speed and accuracy with our drill-based testing system.
|
||||
</p>
|
||||
</header>
|
||||
<section>
|
||||
<div className="relative overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<div className="dr-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="dr-blob dr-blob-1" />
|
||||
<div className="dr-blob dr-blob-2" />
|
||||
<div className="dr-blob dr-blob-3" />
|
||||
<div className="dr-blob dr-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="dr-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 + i * 0.5}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="dr-inner">
|
||||
{/* Header */}
|
||||
<div className="dr-header-row dr-anim">
|
||||
<button
|
||||
className={`dr-back-btn${step === "topic" ? " hidden" : ""}`}
|
||||
onClick={goBack}
|
||||
>
|
||||
<ArrowLeft size={17} color="#6b7280" />
|
||||
</button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p className="dr-eyebrow">
|
||||
<Zap size={11} /> Drills
|
||||
</p>
|
||||
<h1 className="dr-title">
|
||||
{step === "topic" ? "Pick your topics" : "Review & launch"}
|
||||
</h1>
|
||||
<p className="dr-sub">
|
||||
{step === "topic"
|
||||
? "Choose what you want to drill. Speed and accuracy await."
|
||||
: "Everything look good? Time to drill."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="dr-anim dr-anim-1">
|
||||
<div className="dr-progress-wrap">
|
||||
<div
|
||||
className="dr-progress-fill"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="dr-progress-labels">
|
||||
{STEPS.map((s, i) => (
|
||||
<span key={s} className={i <= stepIndex ? "done" : ""}>
|
||||
{s === "topic" ? "Topics" : "Review"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div style={{ position: "relative", overflow: "hidden" }}>
|
||||
<AnimatePresence mode="wait" custom={direction}>
|
||||
{/* Step 1 — Topic */}
|
||||
{step === "topic" && (
|
||||
<motion.div
|
||||
custom={direction}
|
||||
key="topic"
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="space-y-4"
|
||||
>
|
||||
<h2 className="text-xl font-satoshi-bold">Choose a topic</h2>
|
||||
<div className="dr-step-card">
|
||||
<div className="dr-step-title">
|
||||
Choose topics
|
||||
{selectedTopics.length > 0 && (
|
||||
<span className="dr-step-badge">
|
||||
{selectedTopics.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
placeholder="Search topics..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-xl border px-4 py-2"
|
||||
/>
|
||||
<div className="dr-search-wrap">
|
||||
<Search size={15} className="dr-search-icon" />
|
||||
<input
|
||||
className="dr-search-input"
|
||||
placeholder="Search topics..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{loading ? (
|
||||
<>
|
||||
<div>
|
||||
<Loader2
|
||||
size={30}
|
||||
color="purple"
|
||||
className="animate-spin"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className="dr-loading">
|
||||
<Loader2
|
||||
size={22}
|
||||
color="#0891b2"
|
||||
className="dr-spinner"
|
||||
/>
|
||||
Loading topics...
|
||||
</div>
|
||||
) : filteredTopics.length === 0 ? (
|
||||
<p className="dr-empty">No topics match "{search}"</p>
|
||||
) : (
|
||||
topics
|
||||
.filter((t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
.map((t) => (
|
||||
<div className="dr-topic-grid">
|
||||
{filteredTopics.map((t) => (
|
||||
<ChoiceCard
|
||||
key={t.id}
|
||||
label={t.name}
|
||||
@ -126,87 +443,109 @@ export const Drills = () => {
|
||||
selected={selectedTopics.some((st) => st.id === t.id)}
|
||||
onClick={() => toggleTopic(t)}
|
||||
/>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
disabled={selectedTopics.length === 0}
|
||||
onClick={() => {
|
||||
// ✅ STORE
|
||||
storeTopics(selectedTopics.map((t) => t.id)); // ✅ STORE
|
||||
setMode("DRILL"); // ✅ STORE
|
||||
setQuestionCount(7); // ✅ STORE
|
||||
setDirection(1);
|
||||
setStep("review");
|
||||
}}
|
||||
className={`rounded-2xl py-3 px-6 font-satoshi-bold transition
|
||||
${
|
||||
selectedTopics.length === 0
|
||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Review */}
|
||||
{step === "review" && (
|
||||
<motion.div
|
||||
custom={direction}
|
||||
key="review"
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-xl font-satoshi-bold">
|
||||
Review your choices
|
||||
</h2>
|
||||
<div className="dr-step-card">
|
||||
<p className="dr-step-title">Your drill setup</p>
|
||||
|
||||
<div className="rounded-2xl border p-4 space-y-2 font-satoshi">
|
||||
<p>
|
||||
<strong>Topics:</strong>{" "}
|
||||
{selectedTopics.map((t) => t.name).join(", ")}
|
||||
</p>
|
||||
{/* Topics */}
|
||||
<div className="dr-review-row">
|
||||
<div
|
||||
className="dr-review-icon"
|
||||
style={{
|
||||
background: "#ecfeff",
|
||||
border: "2px solid #a5f3fc",
|
||||
}}
|
||||
>
|
||||
📚
|
||||
</div>
|
||||
<div>
|
||||
<p className="dr-review-label">Topics</p>
|
||||
<div className="dr-chip-wrap">
|
||||
{selectedTopics.map((t) => (
|
||||
<span key={t.id} className="dr-chip">
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="dr-review-row">
|
||||
<div
|
||||
className="dr-review-icon"
|
||||
style={{
|
||||
background: "#ecfeff",
|
||||
border: "2px solid #a5f3fc",
|
||||
}}
|
||||
>
|
||||
⚡
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p className="dr-review-label">Session</p>
|
||||
<div className="dr-stat-row">
|
||||
<div className="dr-stat">
|
||||
<span className="dr-stat-val">7</span>
|
||||
<span className="dr-stat-label">Questions</span>
|
||||
</div>
|
||||
<div className="dr-stat">
|
||||
<span className="dr-stat-val">~5</span>
|
||||
<span className="dr-stat-label">Minutes</span>
|
||||
</div>
|
||||
<div className="dr-stat">
|
||||
<span className="dr-stat-val">⏱️</span>
|
||||
<span className="dr-stat-label">Timed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<button
|
||||
disabled={step === "topic"}
|
||||
onClick={() => {
|
||||
const order: Step[] = ["topic", "review"];
|
||||
setDirection(-1);
|
||||
setStep(order[order.indexOf(step) - 1]);
|
||||
}}
|
||||
className={`absolute bottom-24 left-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
|
||||
${
|
||||
step === "topic"
|
||||
? "opacity-0 pointer-events-none"
|
||||
: "bg-linear-to-br from-slate-500 to-slate-600 text-white"
|
||||
}`}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={step !== "review"}
|
||||
className={`absolute bottom-24 right-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
|
||||
${
|
||||
step !== "review"
|
||||
? "opacity-0 pointer-events-none"
|
||||
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleStartDrill();
|
||||
}}
|
||||
>
|
||||
Start Test
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
{/* CTA bar */}
|
||||
<div className="dr-cta-bar">
|
||||
<div className="dr-cta-inner">
|
||||
{step === "topic" && (
|
||||
<button
|
||||
className="dr-next-btn"
|
||||
disabled={selectedTopics.length === 0}
|
||||
onClick={() => {
|
||||
storeTopics(selectedTopics.map((t) => t.id));
|
||||
setMode("DRILL");
|
||||
setQuestionCount(7);
|
||||
goNext();
|
||||
}}
|
||||
>
|
||||
Next — Review ⚡
|
||||
</button>
|
||||
)}
|
||||
{step === "review" && (
|
||||
<button className="dr-start-btn" onClick={handleStartDrill}>
|
||||
⚡ Start Drill
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,8 +5,8 @@ import {
|
||||
Pilcrow,
|
||||
Superscript,
|
||||
WholeWord,
|
||||
Trophy,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "../../../components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { useAuthStore } from "../../../stores/authStore";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@ -14,106 +14,425 @@ import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||
|
||||
type Module = "EBRW" | "MATH" | null;
|
||||
|
||||
const DOTS = [
|
||||
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
|
||||
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
|
||||
{ size: 9, color: "#22c55e", top: "62%", left: "3%", delay: "0.6s" },
|
||||
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
|
||||
{ size: 7, color: "#f43f5e", top: "48%", right: "2%", delay: "0.9s" },
|
||||
{ size: 9, color: "#eab308", top: "76%", right: "5%", delay: "0.4s" },
|
||||
];
|
||||
|
||||
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');
|
||||
|
||||
.htm-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.htm-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
|
||||
.htm-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:htmWobble1 14s ease-in-out infinite; }
|
||||
.htm-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:htmWobble2 16s ease-in-out infinite; }
|
||||
.htm-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:htmWobble1 18s ease-in-out infinite reverse; }
|
||||
.htm-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:htmWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes htmWobble1 {
|
||||
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(12px,16px) rotate(8deg);}
|
||||
}
|
||||
@keyframes htmWobble2 {
|
||||
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(-10px,12px) rotate(-6deg);}
|
||||
}
|
||||
|
||||
.htm-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:htmFloat 7s ease-in-out infinite; }
|
||||
@keyframes htmFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-12px) rotate(180deg);}
|
||||
}
|
||||
|
||||
.htm-inner {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 560px; margin: 0 auto;
|
||||
padding: 2rem 1.25rem 8rem;
|
||||
display: flex; flex-direction: column; gap: 1.5rem;
|
||||
}
|
||||
|
||||
@keyframes htmPopIn {
|
||||
from { opacity:0; transform:scale(0.92) translateY(12px); }
|
||||
to { opacity:1; transform:scale(1) translateY(0); }
|
||||
}
|
||||
.htm-anim { animation: htmPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.htm-anim-1 { animation-delay: 0.05s; }
|
||||
.htm-anim-2 { animation-delay: 0.12s; }
|
||||
.htm-anim-3 { animation-delay: 0.19s; }
|
||||
|
||||
/* Header */
|
||||
.htm-eyebrow {
|
||||
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: #84cc16;
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
}
|
||||
.htm-title {
|
||||
font-size: 1.75rem; font-weight: 900; color: #1e1b4b;
|
||||
letter-spacing: -0.02em; line-height: 1.15;
|
||||
}
|
||||
.htm-sub {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.82rem; font-weight: 600; color: #9ca3af; line-height: 1.5;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* Module cards */
|
||||
.htm-card {
|
||||
border-radius: 28px;
|
||||
position: relative; overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 3px solid transparent;
|
||||
transition: transform 0.2s cubic-bezier(0.34,1.56,0.64,1),
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
min-height: 200px;
|
||||
display: flex; flex-direction: column; justify-content: flex-end;
|
||||
}
|
||||
.htm-card:hover { transform: translateY(-4px); }
|
||||
.htm-card:active { transform: translateY(2px) scale(0.98); }
|
||||
|
||||
.htm-card.ebrw {
|
||||
background: linear-gradient(145deg, #3b82f6 0%, #1d4ed8 60%, #1e40af 100%);
|
||||
box-shadow: 0 10px 0 #1e3a8a66, 0 14px 32px rgba(29,78,216,0.3);
|
||||
}
|
||||
.htm-card.ebrw.selected {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 10px 0 #1e3a8a88, 0 16px 40px rgba(29,78,216,0.45);
|
||||
transform: translateY(-4px) scale(1.01);
|
||||
}
|
||||
|
||||
.htm-card.math {
|
||||
background: linear-gradient(145deg, #f43f5e 0%, #e11d48 60%, #be123c 100%);
|
||||
box-shadow: 0 10px 0 #9f123666, 0 14px 32px rgba(225,29,72,0.3);
|
||||
}
|
||||
.htm-card.math.selected {
|
||||
border-color: #fda4af;
|
||||
box-shadow: 0 10px 0 #9f123688, 0 16px 40px rgba(225,29,72,0.45);
|
||||
transform: translateY(-4px) scale(1.01);
|
||||
}
|
||||
|
||||
/* Decorative icons */
|
||||
.htm-card-icons {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
}
|
||||
|
||||
/* Card body (text + chips) */
|
||||
.htm-card-body {
|
||||
position: relative; z-index: 2;
|
||||
padding: 1.5rem 1.5rem 1.75rem;
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
|
||||
.htm-card-tag {
|
||||
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.htm-card-name {
|
||||
font-size: 1.6rem; font-weight: 900; color: white;
|
||||
letter-spacing: -0.02em; line-height: 1.1;
|
||||
}
|
||||
.htm-card-desc {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.78rem; font-weight: 600; color: rgba(255,255,255,0.7);
|
||||
line-height: 1.4; margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Stat pills row */
|
||||
.htm-stat-row {
|
||||
display: flex; gap: 0.4rem; margin-top: 0.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.htm-stat-pill {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: 1.5px solid rgba(255,255,255,0.25);
|
||||
border-radius: 100px; padding: 0.25rem 0.65rem;
|
||||
font-size: 0.65rem; font-weight: 800; color: white;
|
||||
display: flex; align-items: center; gap: 0.3rem;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Selected check badge */
|
||||
.htm-check-badge {
|
||||
position: absolute; top: 1rem; right: 1rem; z-index: 3;
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 2px solid rgba(255,255,255,0.4);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.25s cubic-bezier(0.34,1.56,0.64,1);
|
||||
opacity: 0; transform: scale(0.7);
|
||||
}
|
||||
.htm-card.selected .htm-check-badge {
|
||||
opacity: 1; transform: scale(1);
|
||||
background: rgba(255,255,255,0.3);
|
||||
border-color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
/* CTA bar */
|
||||
.htm-cta-bar {
|
||||
position: fixed; bottom: 96px; left: 0; right: 0; z-index: 10;
|
||||
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
|
||||
|
||||
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.25s ease;
|
||||
}
|
||||
.htm-cta-bar.hidden {
|
||||
transform: translateY(100%); opacity: 0; pointer-events: none;
|
||||
}
|
||||
.htm-cta-inner {
|
||||
max-width: 560px; margin: 0 auto;
|
||||
}
|
||||
|
||||
.htm-start-btn {
|
||||
width: 100%; padding: 0.95rem;
|
||||
background: linear-gradient(135deg, #84cc16, #65a30d);
|
||||
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 #3f6212, 0 8px 20px rgba(101,163,13,0.3);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.htm-start-btn:hover { transform:translateY(-2px); box-shadow:0 8px 0 #3f6212,0 12px 24px rgba(101,163,13,0.35); }
|
||||
.htm-start-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #3f6212; }
|
||||
|
||||
.htm-start-label {
|
||||
font-size: 0.7rem; font-weight: 700; color: #9ca3af;
|
||||
text-align: center; margin-top: 0.5rem;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
.htm-start-label span { font-weight: 800; color: #6b7280; }
|
||||
`;
|
||||
|
||||
export const HardTestModules = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const navigate = useNavigate();
|
||||
const [selected, setSelected] = useState<Module>(null);
|
||||
|
||||
const { setMode, storeDuration, setSection } = useExamConfigStore();
|
||||
|
||||
function handleStartModule() {
|
||||
if (!user) return;
|
||||
|
||||
(setMode("MODULE"), storeDuration(7), setSection(selected));
|
||||
|
||||
if (!user || !selected) return;
|
||||
setMode("MODULE");
|
||||
storeDuration(7);
|
||||
setSection(selected);
|
||||
navigate(`/student/practice/${selected}/test`, { replace: true });
|
||||
}
|
||||
|
||||
const toggle = (mod: "EBRW" | "MATH") =>
|
||||
setSelected((prev) => (prev === mod ? null : mod));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
||||
<header className="space-y-2">
|
||||
<h1 className="font-satoshi-bold text-3xl">Hard Test Modules</h1>
|
||||
<p className="font-satoshi text-md text-gray-500">
|
||||
Tackle hard practice test modules by selecting a section.
|
||||
</p>
|
||||
</header>
|
||||
<section className="space-y-6">
|
||||
<Card
|
||||
onClick={() =>
|
||||
setSelected((prev) => (prev === "EBRW" ? null : "EBRW"))
|
||||
<div className="htm-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="htm-blob htm-blob-1" />
|
||||
<div className="htm-blob htm-blob-2" />
|
||||
<div className="htm-blob htm-blob-3" />
|
||||
<div className="htm-blob htm-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="htm-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 + i * 0.5}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`relative cursor-pointer overflow-hidden transition
|
||||
${
|
||||
selected === "EBRW"
|
||||
? "ring-2 ring-blue-500 scale-[1.02]"
|
||||
: "hover:scale-[1.01]"
|
||||
}
|
||||
bg-linear-to-br from-blue-400 to-blue-600
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="htm-inner">
|
||||
{/* Header */}
|
||||
<header className="htm-anim">
|
||||
<p className="htm-eyebrow">
|
||||
<Trophy size={11} /> Hard Modules
|
||||
</p>
|
||||
<h1 className="htm-title">Pick your challenge</h1>
|
||||
<p className="htm-sub">
|
||||
Tackle the hardest SAT questions. Select a section to begin.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* EBRW Card */}
|
||||
<div
|
||||
className={`htm-card ebrw htm-anim htm-anim-1${selected === "EBRW" ? " selected" : ""}`}
|
||||
onClick={() => toggle("EBRW")}
|
||||
>
|
||||
<CardContent className="z-10 flex items-center justify-center py-16 ">
|
||||
<h1 className="font-satoshi-bold text-2xl text-blue-50">
|
||||
Reading & Writing
|
||||
</h1>
|
||||
</CardContent>
|
||||
<Languages
|
||||
size={250}
|
||||
className="absolute -top-5 -right-10 -rotate-23 text-white opacity-30"
|
||||
/>
|
||||
<WholeWord
|
||||
size={150}
|
||||
className="absolute -top-10 -left-3 rotate-23 text-white opacity-30"
|
||||
/>
|
||||
<Pilcrow
|
||||
size={150}
|
||||
className="absolute -bottom-12 left-8 -rotate-23 text-white opacity-30"
|
||||
/>
|
||||
</Card>
|
||||
<Card
|
||||
onClick={() =>
|
||||
setSelected((prev) => (prev === "MATH" ? null : "MATH"))
|
||||
}
|
||||
className={`relative cursor-pointer overflow-hidden transition
|
||||
${
|
||||
selected === "MATH"
|
||||
? "ring-2 ring-rose-500 scale-[1.02]"
|
||||
: "hover:scale-[1.01]"
|
||||
}
|
||||
bg-linear-to-br from-rose-400 to-rose-600
|
||||
`}
|
||||
>
|
||||
<CardContent className="z-10 flex items-center justify-center py-16 ">
|
||||
<h1 className="font-satoshi-bold text-2xl text-blue-50">
|
||||
Mathematics
|
||||
</h1>
|
||||
</CardContent>
|
||||
<DecimalsArrowRight
|
||||
size={250}
|
||||
className="absolute -top-5 -right-10 -rotate-23 text-white opacity-30"
|
||||
/>
|
||||
<Superscript
|
||||
size={150}
|
||||
className="absolute -top-10 -left-3 rotate-23 text-white opacity-30"
|
||||
/>
|
||||
<Percent
|
||||
size={120}
|
||||
className="absolute -bottom-5 left-8 -rotate-10 text-white opacity-30"
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
{selected && (
|
||||
<div className=" bottom-6 left-0 right-0 flex justify-center z-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleStartModule();
|
||||
}}
|
||||
className="rounded-2xl px-10 py-4 font-satoshi-bold text-lg
|
||||
bg-linear-to-br from-purple-500 to-purple-600 text-white
|
||||
shadow-xl animate-in slide-in-from-bottom-4"
|
||||
>
|
||||
Start Test
|
||||
</button>
|
||||
{/* Background icons */}
|
||||
<div className="htm-card-icons">
|
||||
<Languages
|
||||
size={220}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -20,
|
||||
right: -30,
|
||||
transform: "rotate(-20deg)",
|
||||
color: "white",
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
<WholeWord
|
||||
size={130}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -15,
|
||||
left: -10,
|
||||
transform: "rotate(15deg)",
|
||||
color: "white",
|
||||
opacity: 0.1,
|
||||
}}
|
||||
/>
|
||||
<Pilcrow
|
||||
size={110}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: -20,
|
||||
left: 20,
|
||||
transform: "rotate(-18deg)",
|
||||
color: "white",
|
||||
opacity: 0.1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Check */}
|
||||
<div className="htm-check-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path
|
||||
d="M2.5 7L5.5 10L11.5 4"
|
||||
stroke="white"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="htm-card-body">
|
||||
<span className="htm-card-tag">Section 1</span>
|
||||
<h2 className="htm-card-name">
|
||||
Reading &
|
||||
<br />
|
||||
Writing
|
||||
</h2>
|
||||
<p className="htm-card-desc">
|
||||
Grammar, vocabulary, comprehension & evidence-based analysis
|
||||
</p>
|
||||
<div className="htm-stat-row">
|
||||
<span className="htm-stat-pill">📖 27 Questions</span>
|
||||
<span className="htm-stat-pill">⏱️ 32 min</span>
|
||||
<span className="htm-stat-pill">🔥 Hard tier</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* MATH Card */}
|
||||
<div
|
||||
className={`htm-card math htm-anim htm-anim-2${selected === "MATH" ? " selected" : ""}`}
|
||||
onClick={() => toggle("MATH")}
|
||||
>
|
||||
{/* Background icons */}
|
||||
<div className="htm-card-icons">
|
||||
<DecimalsArrowRight
|
||||
size={220}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -20,
|
||||
right: -30,
|
||||
transform: "rotate(-20deg)",
|
||||
color: "white",
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
<Superscript
|
||||
size={130}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -15,
|
||||
left: -10,
|
||||
transform: "rotate(15deg)",
|
||||
color: "white",
|
||||
opacity: 0.1,
|
||||
}}
|
||||
/>
|
||||
<Percent
|
||||
size={110}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: -15,
|
||||
left: 20,
|
||||
transform: "rotate(-10deg)",
|
||||
color: "white",
|
||||
opacity: 0.1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Check */}
|
||||
<div className="htm-check-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path
|
||||
d="M2.5 7L5.5 10L11.5 4"
|
||||
stroke="white"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="htm-card-body">
|
||||
<span className="htm-card-tag">Section 2</span>
|
||||
<h2 className="htm-card-name">Mathematics</h2>
|
||||
<p className="htm-card-desc">
|
||||
Algebra, advanced math, geometry & data analysis under
|
||||
pressure
|
||||
</p>
|
||||
<div className="htm-stat-row">
|
||||
<span className="htm-stat-pill">🔢 22 Questions</span>
|
||||
<span className="htm-stat-pill">⏱️ 35 min</span>
|
||||
<span className="htm-stat-pill">🔥 Hard tier</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA bar */}
|
||||
<div className={`htm-cta-bar${!selected ? " hidden" : ""}`}>
|
||||
<div className="htm-cta-inner">
|
||||
<button className="htm-start-btn" onClick={handleStartModule}>
|
||||
🏆 Start{" "}
|
||||
{selected === "EBRW"
|
||||
? "Reading & Writing"
|
||||
: selected === "MATH"
|
||||
? "Mathematics"
|
||||
: ""}{" "}
|
||||
Module
|
||||
</button>
|
||||
<p className="htm-start-label">
|
||||
Tap again to <span>deselect</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user