Files
edbridge-scholars/src/components/PredictedScoreCard.tsx
2026-03-12 02:39:34 +06:00

476 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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&amp;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>
);
};