feat(ui): improve ui for test, drills and htm screens

This commit is contained in:
shafin-r
2026-02-21 02:04:50 +06:00
parent 76d2108aec
commit 65dbe99647
10 changed files with 3325 additions and 1464 deletions

View File

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