476 lines
14 KiB
TypeScript
476 lines
14 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { api } from "../utils/api";
|
||
import { useAuthToken } from "../hooks/useAuthToken";
|
||
import {
|
||
TrendingUp,
|
||
BookOpen,
|
||
Calculator,
|
||
Loader2,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
} from "lucide-react";
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
interface SectionPrediction {
|
||
score: number;
|
||
range_min: number;
|
||
range_max: number;
|
||
confidence: string;
|
||
}
|
||
|
||
interface PredictedScoreResponse {
|
||
total_score: number;
|
||
math_prediction: SectionPrediction;
|
||
rw_prediction: SectionPrediction;
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
const confidenceConfig: Record<
|
||
string,
|
||
{ label: string; color: string; bg: string; border: string; dot: string }
|
||
> = {
|
||
high: {
|
||
label: "High confidence",
|
||
color: "#16a34a",
|
||
bg: "#f0fdf4",
|
||
border: "#bbf7d0",
|
||
dot: "#22c55e",
|
||
},
|
||
medium: {
|
||
label: "Medium confidence",
|
||
color: "#d97706",
|
||
bg: "#fffbeb",
|
||
border: "#fde68a",
|
||
dot: "#f59e0b",
|
||
},
|
||
low: {
|
||
label: "Low confidence",
|
||
color: "#e11d48",
|
||
bg: "#fff1f2",
|
||
border: "#fecdd3",
|
||
dot: "#f43f5e",
|
||
},
|
||
};
|
||
|
||
const getConfidenceStyle = (confidence: string) =>
|
||
confidenceConfig[confidence.toLowerCase()] ?? {
|
||
label: confidence,
|
||
color: "#6b7280",
|
||
bg: "#f9fafb",
|
||
border: "#f3f4f6",
|
||
dot: "#9ca3af",
|
||
};
|
||
|
||
const useCountUp = (target: number, duration = 900) => {
|
||
const [value, setValue] = useState(0);
|
||
useEffect(() => {
|
||
if (!target) return;
|
||
let start: number | null = null;
|
||
const step = (ts: number) => {
|
||
if (!start) start = ts;
|
||
const progress = Math.min((ts - start) / duration, 1);
|
||
const eased = 1 - Math.pow(1 - progress, 3);
|
||
setValue(Math.floor(eased * target));
|
||
if (progress < 1) requestAnimationFrame(step);
|
||
};
|
||
requestAnimationFrame(step);
|
||
}, [target, duration]);
|
||
return value;
|
||
};
|
||
|
||
// ─── 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,
|
||
iconBg,
|
||
barColor,
|
||
}: {
|
||
label: string;
|
||
icon: React.ElementType;
|
||
prediction: SectionPrediction;
|
||
iconBg: string;
|
||
barColor: string;
|
||
}) => {
|
||
const conf = getConfidenceStyle(prediction.confidence);
|
||
const pct = (v: number) => ((v - 200) / (800 - 200)) * 100;
|
||
|
||
return (
|
||
<div className="psc-detail-card">
|
||
<div className="psc-detail-top">
|
||
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
|
||
{/* @ts-ignore */}
|
||
<Icon size={15} color={barColor} />
|
||
</div>
|
||
<span className="psc-detail-label">{label}</span>
|
||
<div
|
||
className="psc-conf-badge"
|
||
style={{
|
||
background: conf.bg,
|
||
borderColor: conf.border,
|
||
color: conf.color,
|
||
}}
|
||
>
|
||
<div className="psc-conf-dot" style={{ background: conf.dot }} />
|
||
{conf.label}
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
);
|
||
};
|
||
|
||
// ─── Main component ───────────────────────────────────────────────────────────
|
||
|
||
let stylesInjected = false;
|
||
|
||
export const PredictedScoreCard = () => {
|
||
const token = useAuthToken();
|
||
const [data, setData] = useState<PredictedScoreResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!token) return;
|
||
(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const result = await api.fetchPredictedScore(token);
|
||
setData(result);
|
||
} catch (err) {
|
||
setError("Couldn't load your predicted score.");
|
||
console.error(err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, [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 (
|
||
<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>
|
||
<div className="psc-header-icon">
|
||
<TrendingUp size={17} color="white" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="psc-body">
|
||
{loading && (
|
||
<div className="psc-loading">
|
||
<Loader2 size={20} color="#a855f7" className="psc-spinner" />
|
||
Calculating your score...
|
||
</div>
|
||
)}
|
||
|
||
{error && !loading && <div className="psc-error">⚠️ {error}</div>}
|
||
|
||
{data && !loading && (
|
||
<>
|
||
{/* Score cells */}
|
||
<div className="psc-scores-row">
|
||
{/* Total */}
|
||
<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>
|
||
|
||
{/* Math */}
|
||
<div className="psc-score-cell">
|
||
<div className="psc-cell-label">
|
||
<Calculator size={10} color="#7c3aed" /> Math
|
||
</div>
|
||
<span className="psc-cell-score medium">
|
||
{data.math_prediction.score}
|
||
</span>
|
||
<span className="psc-cell-out">/ 800</span>
|
||
</div>
|
||
|
||
{/* R&W */}
|
||
<div className="psc-score-cell">
|
||
<div className="psc-cell-label">
|
||
<BookOpen size={10} color="#0891b2" /> R&W
|
||
</div>
|
||
<span className="psc-cell-score medium">
|
||
{data.rw_prediction.score}
|
||
</span>
|
||
<span className="psc-cell-out">/ 800</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Toggle */}
|
||
<button
|
||
className="psc-toggle-btn"
|
||
onClick={() => setExpanded((p) => !p)}
|
||
>
|
||
{expanded ? (
|
||
<>
|
||
<ChevronUp size={13} /> Less detail
|
||
</>
|
||
) : (
|
||
<>
|
||
<ChevronDown size={13} /> Score breakdown
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
{/* Expanded */}
|
||
{expanded && (
|
||
<div className="psc-expanded-wrap">
|
||
<SectionDetail
|
||
label="Mathematics"
|
||
icon={Calculator}
|
||
prediction={data.math_prediction}
|
||
iconBg="#fdf4ff"
|
||
barColor="#a855f7"
|
||
/>
|
||
<SectionDetail
|
||
label="Reading & Writing"
|
||
icon={BookOpen}
|
||
prediction={data.rw_prediction}
|
||
iconBg="#ecfeff"
|
||
barColor="#0891b2"
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|