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 (
{/* @ts-ignore */}
{label}
{conf.label}
{prediction.score}
Range
{prediction.range_min}–{prediction.range_max}
200 800
); }; // ─── Main component ─────────────────────────────────────────────────────────── let stylesInjected = false; export const PredictedScoreCard = () => { const token = useAuthToken(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
{/* Header */}

Predicted SAT Score

Based on your practice performance

{/* Body */}
{loading && (
Calculating your score...
)} {error && !loading &&
⚠️ {error}
} {data && !loading && ( <> {/* Score cells */}
{/* Total */}
Total
{animatedTotal} / 1600
{/* Math */}
Math
{data.math_prediction.score} / 800
{/* R&W */}
R&W
{data.rw_prediction.score} / 800
{/* Toggle */} {/* Expanded */} {expanded && (
)} )}
); };