fix(ui): change ui theme color
feat(calc): add geogebra based graph calculator for tests
This commit is contained in:
@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@ -1544,6 +1547,9 @@ packages:
|
||||
caniuse-lite@1.0.30001762:
|
||||
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
|
||||
|
||||
canvas-confetti@1.9.4:
|
||||
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -3706,6 +3712,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001762: {}
|
||||
|
||||
canvas-confetti@1.9.4: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
250
src/components/Calculator.tsx
Normal file
250
src/components/Calculator.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";
|
||||
|
||||
// ─── GeoGebra type shim ───────────────────────────────────────────────────────
|
||||
declare global {
|
||||
interface Window {
|
||||
GGBApplet: new (
|
||||
params: Record<string, unknown>,
|
||||
defer?: boolean,
|
||||
) => {
|
||||
inject: (containerId: string) => void;
|
||||
};
|
||||
ggbApplet?: {
|
||||
reset: () => void;
|
||||
setXML: (xml: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook: load GeoGebra script once ─────────────────────────────────────────
|
||||
const GEOGEBRA_SCRIPT = "https://www.geogebra.org/apps/deployggb.js";
|
||||
|
||||
const useGeoGebraScript = () => {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (document.querySelector(`script[src="${GEOGEBRA_SCRIPT}"]`)) {
|
||||
if (window.GGBApplet) setReady(true);
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = GEOGEBRA_SCRIPT;
|
||||
script.async = true;
|
||||
script.onload = () => setReady(true);
|
||||
document.head.appendChild(script);
|
||||
}, []);
|
||||
|
||||
return ready;
|
||||
};
|
||||
|
||||
// ─── GeoGebra Calculator ──────────────────────────────────────────────────────
|
||||
const GeoGebraCalculator = ({ containerId }: { containerId: string }) => {
|
||||
const scriptReady = useGeoGebraScript();
|
||||
const injected = useRef(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
// Measure the wrapper first — GeoGebra needs explicit px dimensions
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) {
|
||||
setDims({ w: Math.floor(width), h: Math.floor(height) });
|
||||
}
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scriptReady || !dims || injected.current) return;
|
||||
injected.current = true;
|
||||
|
||||
const params = {
|
||||
appName: "graphing",
|
||||
width: dims.w,
|
||||
height: dims.h,
|
||||
showToolBar: true,
|
||||
showAlgebraInput: true,
|
||||
showMenuBar: false,
|
||||
enableLabelDrags: true,
|
||||
enableShiftDragZoom: true,
|
||||
enableRightClick: true,
|
||||
showZoomButtons: true,
|
||||
capturingThreshold: null,
|
||||
showFullscreenButton: false,
|
||||
|
||||
scale: 1,
|
||||
disableAutoScale: false,
|
||||
allowUpscale: false,
|
||||
clickToLoad: false,
|
||||
appletOnLoad: () => {},
|
||||
useBrowserForJS: false,
|
||||
showLogging: false,
|
||||
errorDialogsActive: true,
|
||||
showTutorialLink: false,
|
||||
showSuggestionButtons: false,
|
||||
language: "en",
|
||||
id: "ggbApplet",
|
||||
};
|
||||
|
||||
try {
|
||||
const applet = new window.GGBApplet(params, true);
|
||||
applet.inject(containerId);
|
||||
} catch (e) {
|
||||
console.error("GeoGebra init error:", e);
|
||||
}
|
||||
}, [scriptReady, dims, containerId]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="w-full h-full">
|
||||
{!dims && (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-sm font-satoshi gap-2">
|
||||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
Loading calculator...
|
||||
</div>
|
||||
)}
|
||||
<div id={containerId} style={{ width: dims?.w, height: dims?.h }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Modal ────────────────────────────────────────────────────────────────────
|
||||
interface GraphCalculatorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GraphCalculatorModal = ({ open, onClose }: GraphCalculatorModalProps) => {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const containerId = "geogebra-container";
|
||||
|
||||
// Trap focus & keyboard dismiss
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Prevent body scroll while open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Graph Calculator"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`
|
||||
relative z-10 flex flex-col bg-white rounded-2xl shadow-2xl overflow-hidden
|
||||
transition-all duration-300
|
||||
${
|
||||
fullscreen
|
||||
? "w-screen h-screen rounded-none"
|
||||
: "w-[95vw] h-[90vh] max-w-5xl"
|
||||
}
|
||||
`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-white shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-1.5 rounded-lg bg-purple-50 border border-purple-100">
|
||||
<Calculator size={16} className="text-purple-500" />
|
||||
</div>
|
||||
<span className="font-satoshi-bold text-gray-800 text-sm">
|
||||
Graph Calculator
|
||||
</span>
|
||||
<span className="text-[10px] font-satoshi text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||
Powered by GeoGebra
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setFullscreen((f) => !f)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition"
|
||||
title={fullscreen ? "Exit fullscreen" : "Fullscreen"}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition"
|
||||
title="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GeoGebra canvas area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<GeoGebraCalculator containerId={containerId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Trigger button + modal — drop this wherever you need it ──────────────────
|
||||
export const GraphCalculatorButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-full font-satoshi-medium text-sm"
|
||||
>
|
||||
<Calculator size={16} />
|
||||
Calculator
|
||||
</button>
|
||||
|
||||
<GraphCalculatorModal open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Standalone modal export if you need to control it externally ─────────────
|
||||
export { GraphCalculatorModal };
|
||||
@ -16,7 +16,7 @@ export const ChoiceCard = ({
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded-2xl border p-4 text-left transition flex flex-col
|
||||
${selected ? "border-purple-600 bg-purple-50" : "hover:border-gray-300"}`}
|
||||
${selected ? "border-indigo-600 bg-indigo-50" : "hover:border-gray-300"}`}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-satoshi-bold text-lg">{label}</span>
|
||||
|
||||
634
src/components/GraphPlotter.tsx
Normal file
634
src/components/GraphPlotter.tsx
Normal file
@ -0,0 +1,634 @@
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Equation {
|
||||
/** A JS math expression in terms of x. e.g. "Math.sin(x)", "x**2 - 3" */
|
||||
fn: string;
|
||||
/** Hex or CSS color string */
|
||||
color?: string;
|
||||
/** Display label e.g. "y = x²" */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface GraphPlotterProps {
|
||||
equations: Equation[];
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
interface Intersection {
|
||||
x: number;
|
||||
y: number;
|
||||
eqA: number;
|
||||
eqB: number;
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
mathX: number;
|
||||
mathY: number;
|
||||
eqA: number;
|
||||
eqB: number;
|
||||
}
|
||||
|
||||
// ─── Palette ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
"#e05263", // crimson-rose
|
||||
"#3b82f6", // blue
|
||||
"#10b981", // emerald
|
||||
"#f59e0b", // amber
|
||||
"#a855f7", // violet
|
||||
"#06b6d4", // cyan
|
||||
"#f97316", // orange
|
||||
];
|
||||
|
||||
// ─── Safe function evaluator ──────────────────────────────────────────────────
|
||||
|
||||
const buildFn = (expr: string): ((x: number) => number) => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(
|
||||
"x",
|
||||
`"use strict"; try { return ${expr}; } catch(e) { return NaN; }`,
|
||||
) as (x: number) => number;
|
||||
} catch {
|
||||
return () => NaN;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Intersection finder (bisection on sign changes) ─────────────────────────
|
||||
|
||||
const findIntersections = (
|
||||
fns: Array<(x: number) => number>,
|
||||
xMin: number,
|
||||
xMax: number,
|
||||
steps = 800,
|
||||
): Intersection[] => {
|
||||
const results: Intersection[] = [];
|
||||
const dx = (xMax - xMin) / steps;
|
||||
|
||||
for (let a = 0; a < fns.length; a++) {
|
||||
for (let b = a + 1; b < fns.length; b++) {
|
||||
const diff = (x: number) => fns[a](x) - fns[b](x);
|
||||
let prev = diff(xMin);
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x1 = xMin + i * dx;
|
||||
const cur = diff(x1);
|
||||
|
||||
if (isFinite(prev) && isFinite(cur) && prev * cur < 0) {
|
||||
// Bisect
|
||||
let lo = x1 - dx,
|
||||
hi = x1;
|
||||
for (let k = 0; k < 42; k++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const m = diff(mid);
|
||||
if (Math.abs(m) < 1e-10) {
|
||||
lo = hi = mid;
|
||||
break;
|
||||
}
|
||||
if (m * diff(lo) < 0) hi = mid;
|
||||
else lo = mid;
|
||||
}
|
||||
const rx = (lo + hi) / 2;
|
||||
const ry = fns[a](rx);
|
||||
if (isFinite(rx) && isFinite(ry)) {
|
||||
// Dedupe
|
||||
const dupe = results.some(
|
||||
(p) => p.eqA === a && p.eqB === b && Math.abs(p.x - rx) < 1e-4,
|
||||
);
|
||||
if (!dupe) results.push({ x: rx, y: ry, eqA: a, eqB: b });
|
||||
}
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export const GraphPlotter = ({
|
||||
equations,
|
||||
width = "100%",
|
||||
height = 480,
|
||||
}: GraphPlotterProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Viewport state: origin in math coords + pixels-per-unit
|
||||
const [viewport, setViewport] = useState({ cx: 0, cy: 0, scale: 60 });
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 600, h: 480 });
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [activeIntersections, setActiveIntersections] = useState<
|
||||
Intersection[]
|
||||
>([]);
|
||||
|
||||
// Pan state
|
||||
const isPanning = useRef(false);
|
||||
const lastPointer = useRef({ x: 0, y: 0 });
|
||||
const lastPinchDist = useRef<number | null>(null);
|
||||
|
||||
// Build compiled functions
|
||||
const compiledFns = useMemo(
|
||||
() => equations.map((eq) => buildFn(eq.fn)),
|
||||
[equations],
|
||||
);
|
||||
|
||||
// Math → screen
|
||||
const toScreen = useCallback(
|
||||
(mx: number, my: number, vp = viewport, cs = canvasSize) => ({
|
||||
sx: cs.w / 2 + (mx - vp.cx) * vp.scale,
|
||||
sy: cs.h / 2 - (my - vp.cy) * vp.scale,
|
||||
}),
|
||||
[viewport, canvasSize],
|
||||
);
|
||||
|
||||
// Screen → math
|
||||
const toMath = useCallback(
|
||||
(sx: number, sy: number, vp = viewport, cs = canvasSize) => ({
|
||||
mx: vp.cx + (sx - cs.w / 2) / vp.scale,
|
||||
my: vp.cy - (sy - cs.h / 2) / vp.scale,
|
||||
}),
|
||||
[viewport, canvasSize],
|
||||
);
|
||||
|
||||
// Resize observer
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const { width: w, height: h } = e.contentRect;
|
||||
setCanvasSize({ w: Math.floor(w), h: Math.floor(h) });
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Compute intersections when fns or viewport changes
|
||||
useEffect(() => {
|
||||
if (compiledFns.length < 2) {
|
||||
setActiveIntersections([]);
|
||||
return;
|
||||
}
|
||||
const xMin = viewport.cx - canvasSize.w / (2 * viewport.scale);
|
||||
const xMax = viewport.cx + canvasSize.w / (2 * viewport.scale);
|
||||
const its = findIntersections(compiledFns, xMin, xMax);
|
||||
setActiveIntersections(its);
|
||||
}, [compiledFns, viewport, canvasSize]);
|
||||
|
||||
// ── Draw ──────────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { w, h } = canvasSize;
|
||||
canvas.width = w * devicePixelRatio;
|
||||
canvas.height = h * devicePixelRatio;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
|
||||
const vp = viewport;
|
||||
const { sx: ox, sy: oy } = toScreen(0, 0, vp, canvasSize);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "#fafaf9";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid
|
||||
const drawGrid = (
|
||||
unit: number,
|
||||
alpha: number,
|
||||
lineWidth: number,
|
||||
textSize?: number,
|
||||
) => {
|
||||
ctx.strokeStyle = `rgba(180,180,175,${alpha})`;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.beginPath();
|
||||
|
||||
const xStart = Math.floor((0 - ox) / (unit * vp.scale)) - 1;
|
||||
const xEnd = Math.ceil((w - ox) / (unit * vp.scale)) + 1;
|
||||
for (let i = xStart; i <= xEnd; i++) {
|
||||
const sx = ox + i * unit * vp.scale;
|
||||
ctx.moveTo(sx, 0);
|
||||
ctx.lineTo(sx, h);
|
||||
}
|
||||
|
||||
const yStart = Math.floor((oy - h) / (unit * vp.scale)) - 1;
|
||||
const yEnd = Math.ceil(oy / (unit * vp.scale)) + 1;
|
||||
for (let j = yStart; j <= yEnd; j++) {
|
||||
const sy = oy - j * unit * vp.scale;
|
||||
ctx.moveTo(0, sy);
|
||||
ctx.lineTo(w, sy);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Labels
|
||||
if (textSize) {
|
||||
ctx.fillStyle = "#a8a29e";
|
||||
ctx.font = `${textSize}px ui-monospace, monospace`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
for (let i = xStart; i <= xEnd; i++) {
|
||||
if (i === 0) continue;
|
||||
const sx = ox + i * unit * vp.scale;
|
||||
const val = i * unit;
|
||||
const label = Number.isInteger(val) ? val.toString() : val.toFixed(1);
|
||||
ctx.fillText(label, sx, oy + 4);
|
||||
}
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let j = yStart; j <= yEnd; j++) {
|
||||
if (j === 0) continue;
|
||||
const sy = oy - j * unit * vp.scale;
|
||||
const val = j * unit;
|
||||
const label = Number.isInteger(val) ? val.toString() : val.toFixed(1);
|
||||
ctx.fillText(label, ox - 6, sy);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Adaptive grid unit
|
||||
const rawUnit = 1;
|
||||
const targetPixels = 50;
|
||||
const exp = Math.floor(Math.log10(targetPixels / vp.scale));
|
||||
const unit = rawUnit * Math.pow(10, exp);
|
||||
const subUnit = unit / 5;
|
||||
|
||||
drawGrid(subUnit, 0.35, 0.5);
|
||||
drawGrid(unit, 0.7, 0.8, 10);
|
||||
|
||||
// Axes
|
||||
ctx.strokeStyle = "#57534e";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, oy);
|
||||
ctx.lineTo(w, oy);
|
||||
ctx.moveTo(ox, 0);
|
||||
ctx.lineTo(ox, h);
|
||||
ctx.stroke();
|
||||
|
||||
// Arrow heads
|
||||
const arrow = (x: number, y: number, dir: "r" | "u") => {
|
||||
ctx.fillStyle = "#57534e";
|
||||
ctx.beginPath();
|
||||
if (dir === "r") {
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x - 8, y - 4);
|
||||
ctx.lineTo(x - 8, y + 4);
|
||||
} else {
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x - 4, y + 8);
|
||||
ctx.lineTo(x + 4, y + 8);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
};
|
||||
arrow(w, oy, "r");
|
||||
arrow(ox, 0, "u");
|
||||
|
||||
// Origin label
|
||||
ctx.fillStyle = "#a8a29e";
|
||||
ctx.font = "10px ui-monospace, monospace";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText("0", ox - 5, oy + 4);
|
||||
|
||||
// ── Plot each equation ────────────────────────────────────────────────────
|
||||
|
||||
const xMin = vp.cx - w / (2 * vp.scale);
|
||||
const xMax = vp.cx + w / (2 * vp.scale);
|
||||
const steps = w * 2;
|
||||
const dx = (xMax - xMin) / steps;
|
||||
|
||||
equations.forEach((eq, idx) => {
|
||||
const fn = compiledFns[idx];
|
||||
const color = eq.color ?? DEFAULT_COLORS[idx % DEFAULT_COLORS.length];
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
|
||||
let penDown = false;
|
||||
let prevY = NaN;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const mx = xMin + i * dx;
|
||||
const my = fn(mx);
|
||||
|
||||
if (!isFinite(my)) {
|
||||
penDown = false;
|
||||
prevY = NaN;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Break line on discontinuities (asymptotes)
|
||||
if (Math.abs(my - prevY) > (h / vp.scale) * 2) {
|
||||
penDown = false;
|
||||
}
|
||||
|
||||
const { sx, sy } = toScreen(mx, my, vp, canvasSize);
|
||||
if (!penDown) {
|
||||
ctx.moveTo(sx, sy);
|
||||
penDown = true;
|
||||
} else {
|
||||
ctx.lineTo(sx, sy);
|
||||
}
|
||||
prevY = my;
|
||||
}
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// ── Intersection dots ─────────────────────────────────────────────────────
|
||||
|
||||
activeIntersections.forEach((pt) => {
|
||||
const { sx, sy } = toScreen(pt.x, pt.y, vp, canvasSize);
|
||||
if (sx < 0 || sx > w || sy < 0 || sy > h) return;
|
||||
|
||||
// Outer glow ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 9, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.8)";
|
||||
ctx.fill();
|
||||
|
||||
// Dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#1c1917";
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#fafaf9";
|
||||
ctx.fill();
|
||||
});
|
||||
}, [
|
||||
viewport,
|
||||
canvasSize,
|
||||
equations,
|
||||
compiledFns,
|
||||
activeIntersections,
|
||||
toScreen,
|
||||
]);
|
||||
|
||||
// ── Event handlers ────────────────────────────────────────────────────────
|
||||
|
||||
const zoom = useCallback(
|
||||
(factor: number, pivotSx: number, pivotSy: number) => {
|
||||
setViewport((vp) => {
|
||||
const { mx, my } = toMath(pivotSx, pivotSy, vp, canvasSize);
|
||||
const newScale = Math.max(5, Math.min(2000, vp.scale * factor));
|
||||
return {
|
||||
scale: newScale,
|
||||
cx: mx - (pivotSx - canvasSize.w / 2) / newScale,
|
||||
cy: my + (pivotSy - canvasSize.h / 2) / newScale,
|
||||
};
|
||||
});
|
||||
},
|
||||
[toMath, canvasSize],
|
||||
);
|
||||
|
||||
const onWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
zoom(factor, sx, sy);
|
||||
},
|
||||
[zoom],
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
isPanning.current = true;
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!isPanning.current) return;
|
||||
const dx = e.clientX - lastPointer.current.x;
|
||||
const dy = e.clientY - lastPointer.current.y;
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
setViewport((vp) => ({
|
||||
...vp,
|
||||
cx: vp.cx - dx / vp.scale,
|
||||
cy: vp.cy + dy / vp.scale,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
isPanning.current = false;
|
||||
}, []);
|
||||
|
||||
// Touch pinch-to-zoom
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
lastPinchDist.current = Math.hypot(dx, dy);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2 && lastPinchDist.current !== null) {
|
||||
e.preventDefault();
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const factor = dist / lastPinchDist.current;
|
||||
lastPinchDist.current = dist;
|
||||
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
const pivotX =
|
||||
(e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||
const pivotY =
|
||||
(e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||
zoom(factor, pivotX, pivotY);
|
||||
}
|
||||
},
|
||||
[zoom],
|
||||
);
|
||||
|
||||
// Tap to find nearest intersection
|
||||
const onCanvasClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const { mx, my } = toMath(sx, sy, viewport, canvasSize);
|
||||
|
||||
// Find closest intersection within 20px
|
||||
let best: Intersection | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const pt of activeIntersections) {
|
||||
const { sx: px, sy: py } = toScreen(pt.x, pt.y, viewport, canvasSize);
|
||||
const d = Math.hypot(px - sx, py - sy);
|
||||
if (d < 24 && d < bestDist) {
|
||||
best = pt;
|
||||
bestDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
const { sx: px, sy: py } = toScreen(
|
||||
best.x,
|
||||
best.y,
|
||||
viewport,
|
||||
canvasSize,
|
||||
);
|
||||
setTooltip({
|
||||
screenX: px,
|
||||
screenY: py,
|
||||
mathX: best.x,
|
||||
mathY: best.y,
|
||||
eqA: best.eqA,
|
||||
eqB: best.eqB,
|
||||
});
|
||||
} else {
|
||||
setTooltip(null);
|
||||
}
|
||||
},
|
||||
[activeIntersections, toMath, toScreen, viewport, canvasSize],
|
||||
);
|
||||
|
||||
const fmt = (n: number) => {
|
||||
if (Math.abs(n) < 1e-9) return "0";
|
||||
if (Math.abs(n) >= 1e4 || (Math.abs(n) < 1e-3 && n !== 0))
|
||||
return n.toExponential(3);
|
||||
return parseFloat(n.toFixed(4)).toString();
|
||||
};
|
||||
|
||||
const resetView = () => setViewport({ cx: 0, cy: 0, scale: 60 });
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width, height, position: "relative", userSelect: "none" }}
|
||||
className="rounded-2xl overflow-hidden border border-stone-200 shadow-md bg-stone-50 font-mono pt-32"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: "block", cursor: "crosshair", touchAction: "none" }}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onClick={onCanvasClick}
|
||||
/>
|
||||
|
||||
{/* Equation legend */}
|
||||
<div className="absolute top-3 left-3 flex flex-col gap-1.5">
|
||||
{equations.map((eq, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-white/80 backdrop-blur-sm border border-stone-200 shadow-sm"
|
||||
>
|
||||
<span
|
||||
className="block w-3 h-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
eq.color ?? DEFAULT_COLORS[idx % DEFAULT_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] text-stone-600 leading-none">
|
||||
{eq.label ?? `y = ${eq.fn}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="absolute top-3 right-3 flex flex-col gap-1.5">
|
||||
<button
|
||||
onClick={() => zoom(1.25, canvasSize.w / 2, canvasSize.h / 2)}
|
||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-600 hover:bg-stone-100 transition text-lg flex items-center justify-center"
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zoom(1 / 1.25, canvasSize.w / 2, canvasSize.h / 2)}
|
||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-600 hover:bg-stone-100 transition text-lg flex items-center justify-center"
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-500 hover:bg-stone-100 transition text-[10px] flex items-center justify-center font-sans"
|
||||
title="Reset view"
|
||||
>
|
||||
⌂
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Intersection tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="absolute z-10 pointer-events-none"
|
||||
style={{
|
||||
left: tooltip.screenX,
|
||||
top: tooltip.screenY,
|
||||
transform: "translate(-50%, -130%)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-stone-900 text-stone-100 text-[11px] px-3 py-2 rounded-xl shadow-xl border border-stone-700 whitespace-nowrap">
|
||||
<div className="font-semibold mb-0.5 text-stone-300 text-[10px] tracking-wide uppercase">
|
||||
Intersection
|
||||
</div>
|
||||
<div>
|
||||
x ={" "}
|
||||
<span className="text-amber-300 font-bold">
|
||||
{fmt(tooltip.mathX)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
y ={" "}
|
||||
<span className="text-amber-300 font-bold">
|
||||
{fmt(tooltip.mathY)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-stone-500 text-[9px] mt-1">
|
||||
eq {tooltip.eqA + 1} ∩ eq {tooltip.eqB + 1}
|
||||
</div>
|
||||
</div>
|
||||
{/* Arrow */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-2 h-2 bg-stone-900 rotate-45 -mt-1 border-r border-b border-stone-700" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss tooltip on background click hint */}
|
||||
{tooltip && (
|
||||
<button
|
||||
className="absolute inset-0 w-full h-full bg-transparent"
|
||||
onClick={() => setTooltip(null)}
|
||||
style={{ zIndex: 5 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
305
src/components/PredictedScoreCard.tsx
Normal file
305
src/components/PredictedScoreCard.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
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 {
|
||||
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; dot: string }
|
||||
> = {
|
||||
high: {
|
||||
label: "High confidence",
|
||||
color: "text-emerald-700",
|
||||
bg: "bg-emerald-50 border-emerald-200",
|
||||
dot: "bg-emerald-500",
|
||||
},
|
||||
medium: {
|
||||
label: "Medium confidence",
|
||||
color: "text-amber-700",
|
||||
bg: "bg-amber-50 border-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
},
|
||||
low: {
|
||||
label: "Low confidence",
|
||||
color: "text-rose-700",
|
||||
bg: "bg-rose-50 border-rose-200",
|
||||
dot: "bg-rose-400",
|
||||
},
|
||||
};
|
||||
|
||||
const getConfidenceStyle = (confidence: string) =>
|
||||
confidenceConfig[confidence.toLowerCase()] ?? {
|
||||
label: confidence,
|
||||
color: "text-gray-600",
|
||||
bg: "bg-gray-50 border-gray-200",
|
||||
dot: "bg-gray-400",
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// ─── Expanded section detail ──────────────────────────────────────────────────
|
||||
|
||||
const SectionDetail = ({
|
||||
label,
|
||||
icon: Icon,
|
||||
prediction,
|
||||
accentClass,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
prediction: SectionPrediction;
|
||||
accentClass: string;
|
||||
}) => {
|
||||
const conf = getConfidenceStyle(prediction.confidence);
|
||||
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>
|
||||
<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={`w-1.5 h-1.5 rounded-full ${conf.dot}`} />
|
||||
{conf.label}
|
||||
</span>
|
||||
</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">
|
||||
{prediction.range_min}–{prediction.range_max}
|
||||
</span>
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
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]);
|
||||
|
||||
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>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={26} className="animate-spin text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<p className="font-satoshi text-sm text-rose-500 text-center py-4">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
{/* ── Collapsed view: big numbers only ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
|
||||
{data.math_prediction.score}
|
||||
</span>
|
||||
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
|
||||
out of 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>
|
||||
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
|
||||
{data.rw_prediction.score}
|
||||
</span>
|
||||
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
|
||||
out of 800
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Expand toggle ── */}
|
||||
<button
|
||||
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
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={14} /> More detail
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* ── Expanded: range bars + confidence ── */}
|
||||
{expanded && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<SectionDetail
|
||||
label="Math"
|
||||
icon={Calculator}
|
||||
prediction={data.math_prediction}
|
||||
accentClass="bg-violet-500"
|
||||
/>
|
||||
<SectionDetail
|
||||
label="Reading & Writing"
|
||||
icon={BookOpen}
|
||||
prediction={data.rw_prediction}
|
||||
accentClass="bg-sky-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -6,7 +6,7 @@ import {
|
||||
TabsContent,
|
||||
} from "../../components/ui/tabs";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { CheckCircle, Search } from "lucide-react";
|
||||
import { CheckCircle, Flame, Search, Zap } from "lucide-react";
|
||||
import { api } from "../../utils/api";
|
||||
import {
|
||||
Card,
|
||||
@ -22,10 +22,18 @@ import type { PracticeSheet } from "../../types/sheet";
|
||||
import { formatStatus } from "../../lib/utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { SearchOverlay } from "../../components/SearchOverlay";
|
||||
import { PredictedScoreCard } from "../../components/PredictedScoreCard";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../components/ui/avatar";
|
||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
|
||||
export const Home = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const navigate = useNavigate();
|
||||
const userXp = useExamConfigStore.getState().userXp;
|
||||
|
||||
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
||||
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
||||
@ -84,10 +92,42 @@ export const Home = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-12">
|
||||
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
||||
Welcome, {user?.name || "Student"}
|
||||
</h1>
|
||||
<main className="min-h-screen space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-12">
|
||||
<header className="flex items-center gap-3 justify-between">
|
||||
<div className="flex gap-3">
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarImage src={user?.avatar_url} />
|
||||
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-indigo-400 to-indigo-500 uppercase text-lg text-white">
|
||||
{user?.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
||||
Welcome, {user?.name || "Student"}
|
||||
</h1>
|
||||
<h4 className="text-sm font-satoshi-bold text-indigo-500 ">
|
||||
{user?.role === "STUDENT"
|
||||
? "Student"
|
||||
: user?.role === "ADMIN"
|
||||
? "Admin"
|
||||
: "Taecher"}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="rounded-full w-fit flex items-center gap-2">
|
||||
<Flame size={20} className="text-red-500 fill-amber-200" />
|
||||
|
||||
<span className="font-satoshi-bold text-md">5</span>
|
||||
</div>
|
||||
<div className="rounded-full w-fit flex items-center gap-2">
|
||||
<Zap size={20} className="text-lime-500 fill-lime-200" />
|
||||
|
||||
<span className="font-satoshi-bold text-md">{userXp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<PredictedScoreCard />
|
||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||
What are you looking for?
|
||||
</h1>
|
||||
@ -111,7 +151,7 @@ export const Home = () => {
|
||||
inProgressSheets.map((sheet) => (
|
||||
<Card
|
||||
key={sheet?.id}
|
||||
className="rounded-4xl border bg-purple-50/70 border-purple-500"
|
||||
className="rounded-4xl border bg-indigo-50/70 border-indigo-500"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-satoshi-medium text-xl">
|
||||
@ -122,7 +162,7 @@ export const Home = () => {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-between">
|
||||
<p className="font-satoshi text-sm border px-2 rounded-full bg-purple-500 text-white py-1">
|
||||
<p className="font-satoshi text-sm border px-2 rounded-full bg-indigo-500 text-white py-1">
|
||||
{formatStatus(sheet?.user_status)}
|
||||
</p>
|
||||
<Badge
|
||||
@ -141,7 +181,7 @@ export const Home = () => {
|
||||
<Button
|
||||
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||
variant="outline"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
@ -161,19 +201,19 @@ export const Home = () => {
|
||||
<TabsList className="bg-transparent p-0 w-full">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
All
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="NOT_STARTED"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
Not Started
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="COMPLETED"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
Completed
|
||||
</TabsTrigger>
|
||||
@ -211,7 +251,7 @@ export const Home = () => {
|
||||
<Button
|
||||
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||
variant="outline"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
@ -256,7 +296,7 @@ export const Home = () => {
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 rounded-3xl text-white"
|
||||
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-3xl text-white"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
@ -297,7 +337,7 @@ export const Home = () => {
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 rounded-3xl text-white"
|
||||
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-3xl text-white"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
@ -322,29 +362,29 @@ export const Home = () => {
|
||||
</h1>
|
||||
<section className="space-y-4 ">
|
||||
<div className="flex gap-2">
|
||||
<CheckCircle size={24} color="#AD45FF" />
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">
|
||||
Practice regularly with official SAT materials
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={24} color="#AD45FF" />
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">
|
||||
Review your mistakes and learn from them
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={24} color="#AD45FF" />
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">Focus on your weak areas</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={24} color="#AD45FF" />
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">
|
||||
Take full-length practice tests
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={24} color="#AD45FF" />
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">
|
||||
Get plenty of rest before the test day
|
||||
</p>
|
||||
|
||||
@ -27,17 +27,17 @@ export const Practice = () => {
|
||||
return (
|
||||
<div className="px-8 py-8 space-y-4">
|
||||
<header className="flex justify-between items-center ">
|
||||
<div className="w-fit bg-linear-to-br from-purple-500 to-purple-600 p-3 rounded-2xl">
|
||||
<div className="w-fit bg-linear-to-br from-indigo-500 to-indigo-600 p-3 rounded-2xl">
|
||||
<BookOpen size={20} color="white" />
|
||||
</div>
|
||||
<div className="bg-purple-100 rounded-full w-fit py-2 px-4 flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-linear-to-br from-purple-400 to-purple-500 rounded-full"></div>
|
||||
<div className="bg-indigo-100 rounded-full w-fit py-2 px-4 flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-linear-to-br from-indigo-400 to-indigo-500 rounded-full"></div>
|
||||
<span className="font-satoshi-bold text-md">{userXp}</span>
|
||||
</div>
|
||||
</header>
|
||||
<section>
|
||||
<Card
|
||||
className="relative bg-linear-to-br from-purple-500 to-purple-600 rounded-4xl
|
||||
className="relative bg-linear-to-br from-indigo-500 to-indigo-600 rounded-4xl
|
||||
flex-row"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
|
||||
@ -63,7 +63,7 @@ export const Profile = () => {
|
||||
</section>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full border rounded-4xl bg-purple-500 py-4 px-4 flex justify-center items-center active:bg-purple-600 font-satoshi-medium text-white"
|
||||
className="w-full border rounded-4xl bg-linear-to-br from-indigo-400 to-indigo-600 py-4 px-4 flex justify-center items-center active:bg-purple-600 font-satoshi-medium text-white"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
|
||||
@ -32,7 +32,7 @@ import {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../components/ui/avatar";
|
||||
import { Zap } from "lucide-react";
|
||||
import { Flame, LucideBadgeQuestionMark, Zap } from "lucide-react";
|
||||
import type { Leaderboard } from "../../types/leaderboard";
|
||||
import { api } from "../../utils/api";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
@ -42,6 +42,7 @@ import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
export const Rewards = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const [time, setTime] = useState("bottom");
|
||||
const [activeTab, setActiveTab] = useState("xp");
|
||||
|
||||
const [leaderboard, setLeaderboard] = useState<Leaderboard>();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@ -94,7 +95,7 @@ export const Rewards = () => {
|
||||
) : (
|
||||
<p className="font-satoshi-medium text-md text-gray-500">
|
||||
Don't stop now! You're{" "}
|
||||
<span className="text-purple-400">
|
||||
<span className="text-indigo-400">
|
||||
#{leaderboard?.user_rank.rank}
|
||||
</span>{" "}
|
||||
in XP.
|
||||
@ -103,6 +104,8 @@ export const Rewards = () => {
|
||||
</header>
|
||||
<section className="w-full px-7">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
defaultValue="xp"
|
||||
className="space-y-6 h-[calc(100vh-250px)] flex flex-col"
|
||||
>
|
||||
@ -198,7 +201,7 @@ export const Rewards = () => {
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-satoshi-medium">{user.total_xp}</p>
|
||||
<Zap size={20} color="darkgreen" />
|
||||
<Zap size={20} className="text-lime-500 fill-lime-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -295,7 +298,7 @@ export const Rewards = () => {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
<Card className="absolute mx-auto w-full left-0 md:-bottom-12 bottom-0 bg-linear-to-br from-purple-500 to-purple-600 rounded-full py-4">
|
||||
<Card className="absolute mx-auto w-full left-0 md:-bottom-12 bottom-0 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-full py-4">
|
||||
<CardContent className="flex justify-between items-center">
|
||||
{loading ? (
|
||||
<div className="flex justify-between items-center animate-pulse w-full">
|
||||
@ -332,9 +335,9 @@ export const Rewards = () => {
|
||||
{(leaderboard?.user_rank?.rank ?? Infinity) - 1}
|
||||
</span>
|
||||
)}
|
||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
||||
<Avatar className={`p-6 bg-white`}>
|
||||
<AvatarImage src={leaderboard?.user_rank.avatar_url} />
|
||||
<AvatarFallback className="text-white font-satoshi-bold">
|
||||
<AvatarFallback className=" font-satoshi-bold">
|
||||
{leaderboard?.user_rank.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@ -345,9 +348,20 @@ export const Rewards = () => {
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-satoshi-medium text-white">
|
||||
{leaderboard?.user_rank.total_xp}
|
||||
{activeTab === "xp"
|
||||
? leaderboard?.user_rank.total_xp
|
||||
: activeTab === "questions"
|
||||
? "23"
|
||||
: "5"}
|
||||
</p>
|
||||
<Zap size={20} color="white" />
|
||||
|
||||
{activeTab === "xp" ? (
|
||||
<Zap size={20} color="white" />
|
||||
) : activeTab === "questions" ? (
|
||||
<LucideBadgeQuestionMark size={20} color="white" />
|
||||
) : (
|
||||
<Flame size={20} color="white" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -14,14 +14,18 @@ export function StudentLayout() {
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full overflow-x-hidden">
|
||||
<div className="flex min-h-screen w-full overflow-x-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
<AppSidebar />
|
||||
<main className="flex-1 pb-20 ">
|
||||
<SidebarTrigger className="hidden md:block" />
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<SidebarTrigger className="hidden md:block" />
|
||||
<main className="flex-1 pb-24 md:pb-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 rounded-t-4xl pt-2 bg-white border-t border-gray-200 shadow-4xl z-20 md:hidden">
|
||||
<div className="max-w-7xl mx-auto px-2">
|
||||
<div className="flex justify-around items-center">
|
||||
@ -32,7 +36,7 @@ export function StudentLayout() {
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${
|
||||
isActive
|
||||
? "text-purple-600"
|
||||
? "text-indigo-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`
|
||||
}
|
||||
|
||||
@ -201,7 +201,7 @@ export const Pretest = () => {
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 mx-1 rounded-full ${
|
||||
index + 1 === current ? "bg-purple-500" : "bg-gray-300"
|
||||
index + 1 === current ? "bg-indigo-500" : "bg-gray-300"
|
||||
}`}
|
||||
></div>
|
||||
))}
|
||||
@ -216,7 +216,7 @@ export const Pretest = () => {
|
||||
<Button
|
||||
onClick={() => handleStartTest(practiceSheet?.id!)}
|
||||
variant="outline"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-8 bg-linear-to-br from-purple-500 to-purple-600 text-white active:bg-linear-to-br active:from-purple-600 active:to-purple-700 active:translate-y-1"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-8 bg-linear-to-br from-indigo-500 to-indigo-600 text-white active:bg-linear-to-br active:from-indigo-600 active:to-indigo-700 active:translate-y-1"
|
||||
disabled={!practiceSheet}
|
||||
>
|
||||
{practiceSheet ? (
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { useResults } from "../../../stores/useResults";
|
||||
import { useSatExam } from "../../../stores/useSatExam";
|
||||
import { LucideArrowLeft } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
@ -11,7 +9,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Progress } from "../../../components/ui/progress";
|
||||
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||
@ -33,7 +30,7 @@ const XPGainedCard = ({
|
||||
if (!results?.xp_gained) return;
|
||||
|
||||
let startTime: number | null = null;
|
||||
const duration = 800; // ms
|
||||
const duration = 800;
|
||||
|
||||
const animate = (time: number) => {
|
||||
if (!startTime) startTime = time;
|
||||
@ -58,39 +55,139 @@ const XPGainedCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Targeted static results ──────────────────────────────────────────────────
|
||||
const TARGETED_XP = 15;
|
||||
const TARGETED_SCORE = 15;
|
||||
|
||||
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
|
||||
const { userXp, setUserXp } = useExamConfigStore();
|
||||
|
||||
// previousXP is whatever the user had before; we add 15 on top
|
||||
const previousXP = userXp ?? 0;
|
||||
const gainedXP = TARGETED_XP;
|
||||
const totalXP = previousXP;
|
||||
|
||||
// Sync updated XP back into the store
|
||||
useEffect(() => {
|
||||
setUserXp(totalXP);
|
||||
}, []);
|
||||
|
||||
// Simple level bounds — 0–100 per level so progress is visible
|
||||
// Adjust these to match your real level thresholds if needed
|
||||
const levelMinXP = Math.floor(previousXP / 100) * 100;
|
||||
const levelMaxXP = levelMinXP + 100;
|
||||
const currentLevel = Math.floor(previousXP / 100) + 1;
|
||||
|
||||
const [displayXP, setDisplayXP] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let startTime: number | null = null;
|
||||
const duration = 800;
|
||||
const animate = (time: number) => {
|
||||
if (!startTime) startTime = time;
|
||||
const t = Math.min((time - startTime) / duration, 1);
|
||||
setDisplayXP(Math.floor(t * gainedXP));
|
||||
if (t < 1) requestAnimationFrame(animate);
|
||||
};
|
||||
requestAnimationFrame(animate);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-10">
|
||||
<header className="flex gap-4">
|
||||
<button
|
||||
onClick={onFinish}
|
||||
className="p-2 rounded-full border border-purple-400 bg-linear-to-br from-purple-400 to-purple-500"
|
||||
>
|
||||
<LucideArrowLeft size={20} color="white" />
|
||||
</button>
|
||||
<h1 className="text-3xl font-satoshi-bold">Results</h1>
|
||||
</header>
|
||||
|
||||
{/* Targeted mode badge */}
|
||||
<div className="flex items-center gap-2 bg-purple-50 border border-purple-200 rounded-2xl px-4 py-3">
|
||||
<span className="text-xl">🎯</span>
|
||||
<p className="font-satoshi text-purple-700 text-sm">
|
||||
<strong>Targeted Mode Complete!</strong> You answered all questions
|
||||
correctly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="w-full flex items-center justify-center">
|
||||
<CircularLevelProgress
|
||||
previousXP={previousXP}
|
||||
gainedXP={gainedXP}
|
||||
levelMinXP={levelMinXP}
|
||||
levelMaxXP={levelMaxXP}
|
||||
level={currentLevel}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>XP</CardTitle>
|
||||
<CardDescription>How much did you improve?</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">+{displayXP} XP</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score</CardTitle>
|
||||
<CardDescription>Total score you achieved.</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">{TARGETED_SCORE}</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Keep it up! 🚀</CardTitle>
|
||||
<CardDescription>
|
||||
Great work getting every question right. Keep practicing to level up
|
||||
faster!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<button
|
||||
onClick={onFinish}
|
||||
className="w-full font-satoshi rounded-3xl text-lg py-4 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main Results ─────────────────────────────────────────────────────────────
|
||||
export const Results = () => {
|
||||
const navigate = useNavigate();
|
||||
const results = useResults((s) => s.results);
|
||||
const clearResults = useResults((s) => s.clearResults);
|
||||
|
||||
const { setUserXp } = useExamConfigStore();
|
||||
const { setUserXp, payload } = useExamConfigStore();
|
||||
const isTargeted = payload?.mode === "TARGETED";
|
||||
|
||||
useEffect(() => {
|
||||
if (results) setUserXp(results?.total_xp);
|
||||
}, [results]);
|
||||
|
||||
function handleFinishExam() {
|
||||
useExamConfigStore.getState().clearPayload();
|
||||
clearResults();
|
||||
navigate(`/student/home`);
|
||||
}
|
||||
|
||||
// const [displayXP, setDisplayXP] = useState(0);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!results?.score) return;
|
||||
// let start = 0;
|
||||
// const duration = 600;
|
||||
// const startTime = performance.now();
|
||||
|
||||
// const animate = (time: number) => {
|
||||
// const t = Math.min((time - startTime) / duration, 1);
|
||||
// setDisplayXP(Math.floor(t * results.score));
|
||||
// if (t < 1) requestAnimationFrame(animate);
|
||||
// };
|
||||
|
||||
// requestAnimationFrame(animate);
|
||||
// }, [results?.score]);
|
||||
// ── Targeted mode: show static screen ──────────────────────────────────────
|
||||
if (isTargeted) {
|
||||
return <TargetedResults onFinish={handleFinishExam} />;
|
||||
}
|
||||
|
||||
// ── Standard mode ──────────────────────────────────────────────────────────
|
||||
const previousXP = results ? results.total_xp - results.xp_gained : 0;
|
||||
|
||||
return (
|
||||
@ -107,11 +204,6 @@ export const Results = () => {
|
||||
<section className="w-full flex items-center justify-center">
|
||||
{results && (
|
||||
<CircularLevelProgress
|
||||
// previousXP={505}
|
||||
// gainedXP={605}
|
||||
// levelMinXP={500}
|
||||
// levelMaxXP={1000}
|
||||
// level={3}
|
||||
previousXP={previousXP}
|
||||
gainedXP={results.xp_gained}
|
||||
levelMinXP={results.current_level_start}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -126,7 +126,7 @@ export const TargetedPractice = () => {
|
||||
<div>
|
||||
<Loader2
|
||||
size={30}
|
||||
color="purple"
|
||||
color="indigo"
|
||||
className="animate-spin"
|
||||
/>
|
||||
</div>
|
||||
@ -162,7 +162,7 @@ export const TargetedPractice = () => {
|
||||
${
|
||||
selectedTopics.length === 0
|
||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||
: "bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
@ -249,7 +249,7 @@ export const TargetedPractice = () => {
|
||||
${
|
||||
step !== "review"
|
||||
? "opacity-0 pointer-events-none"
|
||||
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||
: "bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleStartTargetedPractice();
|
||||
|
||||
@ -11,3 +11,19 @@ export interface Leaderboard {
|
||||
top_users: LeaderboardEntry[];
|
||||
user_rank: LeaderboardEntry;
|
||||
}
|
||||
|
||||
export interface PredictedScore {
|
||||
total_score: number;
|
||||
math_prediction: {
|
||||
score: number;
|
||||
range_min: number;
|
||||
range_max: number;
|
||||
confidence: string;
|
||||
};
|
||||
rw_prediction: {
|
||||
score: number;
|
||||
range_min: number;
|
||||
range_max: number;
|
||||
confidence: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ export interface Option {
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
equation?: string[];
|
||||
difficulty: string;
|
||||
text: string;
|
||||
context: string;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Leaderboard } from "../types/leaderboard";
|
||||
import type { Leaderboard, PredictedScore } from "../types/leaderboard";
|
||||
import type { Lesson, LessonsResponse } from "../types/lesson";
|
||||
import type {
|
||||
SessionAnswerResponse,
|
||||
@ -227,5 +227,9 @@ class ApiClient {
|
||||
async fetchLeaderboard(token: string): Promise<Leaderboard> {
|
||||
return this.authenticatedRequest<Leaderboard>(`/leaderboard/`, token);
|
||||
}
|
||||
|
||||
async fetchPredictedScore(token: string): Promise<PredictedScore> {
|
||||
return this.authenticatedRequest<PredictedScore>(`/prediction/`, token);
|
||||
}
|
||||
}
|
||||
export const api = new ApiClient(API_URL);
|
||||
|
||||
Reference in New Issue
Block a user