feat(ui): improve ui for test, drills and htm screens
This commit is contained in:
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user