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",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
|||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.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:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@ -1544,6 +1547,9 @@ packages:
|
|||||||
caniuse-lite@1.0.30001762:
|
caniuse-lite@1.0.30001762:
|
||||||
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
|
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4:
|
||||||
|
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -3706,6 +3712,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001762: {}
|
caniuse-lite@1.0.30001762: {}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
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
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`rounded-2xl border p-4 text-left transition flex flex-col
|
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">
|
<div className="flex justify-between">
|
||||||
<span className="font-satoshi-bold text-lg">{label}</span>
|
<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,
|
TabsContent,
|
||||||
} from "../../components/ui/tabs";
|
} from "../../components/ui/tabs";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
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 { api } from "../../utils/api";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -22,10 +22,18 @@ import type { PracticeSheet } from "../../types/sheet";
|
|||||||
import { formatStatus } from "../../lib/utils";
|
import { formatStatus } from "../../lib/utils";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { SearchOverlay } from "../../components/SearchOverlay";
|
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 = () => {
|
export const Home = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const userXp = useExamConfigStore.getState().userXp;
|
||||||
|
|
||||||
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
||||||
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
||||||
@ -84,10 +92,42 @@ export const Home = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-12">
|
<main className="min-h-screen 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">
|
<header className="flex items-center gap-3 justify-between">
|
||||||
Welcome, {user?.name || "Student"}
|
<div className="flex gap-3">
|
||||||
</h1>
|
<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">
|
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||||
What are you looking for?
|
What are you looking for?
|
||||||
</h1>
|
</h1>
|
||||||
@ -111,7 +151,7 @@ export const Home = () => {
|
|||||||
inProgressSheets.map((sheet) => (
|
inProgressSheets.map((sheet) => (
|
||||||
<Card
|
<Card
|
||||||
key={sheet?.id}
|
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>
|
<CardHeader>
|
||||||
<CardTitle className="font-satoshi-medium text-xl">
|
<CardTitle className="font-satoshi-medium text-xl">
|
||||||
@ -122,7 +162,7 @@ export const Home = () => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex justify-between">
|
<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)}
|
{formatStatus(sheet?.user_status)}
|
||||||
</p>
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
@ -141,7 +181,7 @@ export const Home = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||||
variant="outline"
|
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
|
Resume
|
||||||
</Button>
|
</Button>
|
||||||
@ -161,19 +201,19 @@ export const Home = () => {
|
|||||||
<TabsList className="bg-transparent p-0 w-full">
|
<TabsList className="bg-transparent p-0 w-full">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="all"
|
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
|
All
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="NOT_STARTED"
|
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
|
Not Started
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="COMPLETED"
|
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
|
Completed
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@ -211,7 +251,7 @@ export const Home = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||||
variant="outline"
|
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
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
@ -256,7 +296,7 @@ export const Home = () => {
|
|||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
@ -297,7 +337,7 @@ export const Home = () => {
|
|||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
@ -322,29 +362,29 @@ export const Home = () => {
|
|||||||
</h1>
|
</h1>
|
||||||
<section className="space-y-4 ">
|
<section className="space-y-4 ">
|
||||||
<div className="flex gap-2">
|
<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">
|
<p className="font-satoshi text-md">
|
||||||
Practice regularly with official SAT materials
|
Practice regularly with official SAT materials
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<p className="font-satoshi text-md">
|
||||||
Review your mistakes and learn from them
|
Review your mistakes and learn from them
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<p className="font-satoshi text-md">Focus on your weak areas</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<p className="font-satoshi text-md">
|
||||||
Take full-length practice tests
|
Take full-length practice tests
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<p className="font-satoshi text-md">
|
||||||
Get plenty of rest before the test day
|
Get plenty of rest before the test day
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -27,17 +27,17 @@ export const Practice = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="px-8 py-8 space-y-4">
|
<div className="px-8 py-8 space-y-4">
|
||||||
<header className="flex justify-between items-center ">
|
<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" />
|
<BookOpen size={20} color="white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-100 rounded-full w-fit py-2 px-4 flex items-center gap-2">
|
<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-purple-400 to-purple-500 rounded-full"></div>
|
<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>
|
<span className="font-satoshi-bold text-md">{userXp}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
<Card
|
<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"
|
flex-row"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export const Profile = () => {
|
|||||||
</section>
|
</section>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import {
|
|||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from "../../components/ui/avatar";
|
} from "../../components/ui/avatar";
|
||||||
import { Zap } from "lucide-react";
|
import { Flame, LucideBadgeQuestionMark, Zap } from "lucide-react";
|
||||||
import type { Leaderboard } from "../../types/leaderboard";
|
import type { Leaderboard } from "../../types/leaderboard";
|
||||||
import { api } from "../../utils/api";
|
import { api } from "../../utils/api";
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
@ -42,6 +42,7 @@ import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
|||||||
export const Rewards = () => {
|
export const Rewards = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const [time, setTime] = useState("bottom");
|
const [time, setTime] = useState("bottom");
|
||||||
|
const [activeTab, setActiveTab] = useState("xp");
|
||||||
|
|
||||||
const [leaderboard, setLeaderboard] = useState<Leaderboard>();
|
const [leaderboard, setLeaderboard] = useState<Leaderboard>();
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
@ -94,7 +95,7 @@ export const Rewards = () => {
|
|||||||
) : (
|
) : (
|
||||||
<p className="font-satoshi-medium text-md text-gray-500">
|
<p className="font-satoshi-medium text-md text-gray-500">
|
||||||
Don't stop now! You're{" "}
|
Don't stop now! You're{" "}
|
||||||
<span className="text-purple-400">
|
<span className="text-indigo-400">
|
||||||
#{leaderboard?.user_rank.rank}
|
#{leaderboard?.user_rank.rank}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
in XP.
|
in XP.
|
||||||
@ -103,6 +104,8 @@ export const Rewards = () => {
|
|||||||
</header>
|
</header>
|
||||||
<section className="w-full px-7">
|
<section className="w-full px-7">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
defaultValue="xp"
|
defaultValue="xp"
|
||||||
className="space-y-6 h-[calc(100vh-250px)] flex flex-col"
|
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">
|
<div className="flex items-center gap-1">
|
||||||
<p className="font-satoshi-medium">{user.total_xp}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -295,7 +298,7 @@ export const Rewards = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</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">
|
<CardContent className="flex justify-between items-center">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-between items-center animate-pulse w-full">
|
<div className="flex justify-between items-center animate-pulse w-full">
|
||||||
@ -332,9 +335,9 @@ export const Rewards = () => {
|
|||||||
{(leaderboard?.user_rank?.rank ?? Infinity) - 1}
|
{(leaderboard?.user_rank?.rank ?? Infinity) - 1}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
<Avatar className={`p-6 bg-white`}>
|
||||||
<AvatarImage src={leaderboard?.user_rank.avatar_url} />
|
<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()}
|
{leaderboard?.user_rank.name.slice(0, 1).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -345,9 +348,20 @@ export const Rewards = () => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<p className="font-satoshi-medium text-white">
|
<p className="font-satoshi-medium text-white">
|
||||||
{leaderboard?.user_rank.total_xp}
|
{activeTab === "xp"
|
||||||
|
? leaderboard?.user_rank.total_xp
|
||||||
|
: activeTab === "questions"
|
||||||
|
? "23"
|
||||||
|
: "5"}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -14,14 +14,18 @@ export function StudentLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<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 */}
|
{/* Desktop Sidebar */}
|
||||||
<AppSidebar />
|
<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">
|
<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="max-w-7xl mx-auto px-2">
|
||||||
<div className="flex justify-around items-center">
|
<div className="flex justify-around items-center">
|
||||||
@ -32,7 +36,7 @@ export function StudentLayout() {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${
|
`flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${
|
||||||
isActive
|
isActive
|
||||||
? "text-purple-600"
|
? "text-indigo-600"
|
||||||
: "text-gray-500 hover:text-gray-700"
|
: "text-gray-500 hover:text-gray-700"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -201,7 +201,7 @@ export const Pretest = () => {
|
|||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`w-2 h-2 mx-1 rounded-full ${
|
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>
|
></div>
|
||||||
))}
|
))}
|
||||||
@ -216,7 +216,7 @@ export const Pretest = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => handleStartTest(practiceSheet?.id!)}
|
onClick={() => handleStartTest(practiceSheet?.id!)}
|
||||||
variant="outline"
|
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}
|
disabled={!practiceSheet}
|
||||||
>
|
>
|
||||||
{practiceSheet ? (
|
{practiceSheet ? (
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "../../../components/ui/button";
|
|
||||||
import { useResults } from "../../../stores/useResults";
|
import { useResults } from "../../../stores/useResults";
|
||||||
import { useSatExam } from "../../../stores/useSatExam";
|
|
||||||
import { LucideArrowLeft } from "lucide-react";
|
import { LucideArrowLeft } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -11,7 +9,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
import { Progress } from "../../../components/ui/progress";
|
|
||||||
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
|
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||||
@ -33,7 +30,7 @@ const XPGainedCard = ({
|
|||||||
if (!results?.xp_gained) return;
|
if (!results?.xp_gained) return;
|
||||||
|
|
||||||
let startTime: number | null = null;
|
let startTime: number | null = null;
|
||||||
const duration = 800; // ms
|
const duration = 800;
|
||||||
|
|
||||||
const animate = (time: number) => {
|
const animate = (time: number) => {
|
||||||
if (!startTime) startTime = time;
|
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 = () => {
|
export const Results = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const results = useResults((s) => s.results);
|
const results = useResults((s) => s.results);
|
||||||
const clearResults = useResults((s) => s.clearResults);
|
const clearResults = useResults((s) => s.clearResults);
|
||||||
|
|
||||||
const { setUserXp } = useExamConfigStore();
|
const { setUserXp, payload } = useExamConfigStore();
|
||||||
|
const isTargeted = payload?.mode === "TARGETED";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (results) setUserXp(results?.total_xp);
|
if (results) setUserXp(results?.total_xp);
|
||||||
}, [results]);
|
}, [results]);
|
||||||
|
|
||||||
function handleFinishExam() {
|
function handleFinishExam() {
|
||||||
|
useExamConfigStore.getState().clearPayload();
|
||||||
clearResults();
|
clearResults();
|
||||||
navigate(`/student/home`);
|
navigate(`/student/home`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// const [displayXP, setDisplayXP] = useState(0);
|
// ── Targeted mode: show static screen ──────────────────────────────────────
|
||||||
|
if (isTargeted) {
|
||||||
// useEffect(() => {
|
return <TargetedResults onFinish={handleFinishExam} />;
|
||||||
// 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]);
|
|
||||||
|
|
||||||
|
// ── Standard mode ──────────────────────────────────────────────────────────
|
||||||
const previousXP = results ? results.total_xp - results.xp_gained : 0;
|
const previousXP = results ? results.total_xp - results.xp_gained : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -107,11 +204,6 @@ export const Results = () => {
|
|||||||
<section className="w-full flex items-center justify-center">
|
<section className="w-full flex items-center justify-center">
|
||||||
{results && (
|
{results && (
|
||||||
<CircularLevelProgress
|
<CircularLevelProgress
|
||||||
// previousXP={505}
|
|
||||||
// gainedXP={605}
|
|
||||||
// levelMinXP={500}
|
|
||||||
// levelMaxXP={1000}
|
|
||||||
// level={3}
|
|
||||||
previousXP={previousXP}
|
previousXP={previousXP}
|
||||||
gainedXP={results.xp_gained}
|
gainedXP={results.xp_gained}
|
||||||
levelMinXP={results.current_level_start}
|
levelMinXP={results.current_level_start}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -126,7 +126,7 @@ export const TargetedPractice = () => {
|
|||||||
<div>
|
<div>
|
||||||
<Loader2
|
<Loader2
|
||||||
size={30}
|
size={30}
|
||||||
color="purple"
|
color="indigo"
|
||||||
className="animate-spin"
|
className="animate-spin"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -162,7 +162,7 @@ export const TargetedPractice = () => {
|
|||||||
${
|
${
|
||||||
selectedTopics.length === 0
|
selectedTopics.length === 0
|
||||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
? "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
|
Next
|
||||||
@ -249,7 +249,7 @@ export const TargetedPractice = () => {
|
|||||||
${
|
${
|
||||||
step !== "review"
|
step !== "review"
|
||||||
? "opacity-0 pointer-events-none"
|
? "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={() => {
|
onClick={() => {
|
||||||
handleStartTargetedPractice();
|
handleStartTargetedPractice();
|
||||||
|
|||||||
@ -11,3 +11,19 @@ export interface Leaderboard {
|
|||||||
top_users: LeaderboardEntry[];
|
top_users: LeaderboardEntry[];
|
||||||
user_rank: 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 {
|
export interface Question {
|
||||||
|
equation?: string[];
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
text: string;
|
text: string;
|
||||||
context: 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 { Lesson, LessonsResponse } from "../types/lesson";
|
||||||
import type {
|
import type {
|
||||||
SessionAnswerResponse,
|
SessionAnswerResponse,
|
||||||
@ -227,5 +227,9 @@ class ApiClient {
|
|||||||
async fetchLeaderboard(token: string): Promise<Leaderboard> {
|
async fetchLeaderboard(token: string): Promise<Leaderboard> {
|
||||||
return this.authenticatedRequest<Leaderboard>(`/leaderboard/`, token);
|
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);
|
export const api = new ApiClient(API_URL);
|
||||||
|
|||||||
Reference in New Issue
Block a user