Compare commits

2 Commits

Author SHA1 Message Date
3c8f945539 fix(ui): change ui theme color
feat(calc): add geogebra based graph calculator for tests
2026-02-20 00:03:23 +06:00
626616c8b5 fix(ui): fix minor ui bugs 2026-02-17 16:41:24 +06:00
19 changed files with 2076 additions and 274 deletions

View File

@ -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
View File

@ -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

View 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 };

View File

@ -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>

View 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>
);
};

View 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>
);
};

View File

@ -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>

View File

@ -101,12 +101,12 @@ export const Lessons = () => {
)} )}
{!lessonLoading && lessons.length > 0 && ( {!lessonLoading && lessons.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4">
{lessons.map((lesson) => ( {lessons.map((lesson) => (
<Card <Card
key={lesson.id} key={lesson.id}
onClick={() => handleLessonClick(lesson.id)} onClick={() => handleLessonClick(lesson.id)}
className="py-0 pb-5 rounded-4xl overflow-hidden" className="py-0 pb-5 rounded-4xl overflow-hidden"
> >
<CardHeader className="w-full py-0 px-0"> <CardHeader className="w-full py-0 px-0">
<img <img

View File

@ -24,21 +24,20 @@ export const Practice = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const userXp = useExamConfigStore.getState().userXp; const userXp = useExamConfigStore.getState().userXp;
console.log(userXp);
return ( return (
<main className="h-fit max-w-7xl mx-auto px-8 sm:px-6 lg: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">
@ -136,6 +135,6 @@ export const Practice = () => {
</Card> </Card>
</div> </div>
</section> </section>
</main> </div>
); );
}; };

View File

@ -12,7 +12,7 @@ export const Profile = () => {
}; };
return ( return (
<main className="min-h-screen space-y-6 max-w-7xl mx-auto px-8 sm:px-6 md:px-26 lg:px-8 py-8"> <main className="min-h-screen space-y-6 mx-auto p-8">
<h1 className="text-lg font-satoshi-bold text-center">Profile</h1> <h1 className="text-lg font-satoshi-bold text-center">Profile</h1>
<section> <section>
<h3 className="text-2xl font-satoshi-bold">{user?.name}</h3> <h3 className="text-2xl font-satoshi-bold">{user?.name}</h3>
@ -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>

View File

@ -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);
@ -84,7 +85,7 @@ export const Rewards = () => {
const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) < 3; const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) < 3;
return ( return (
<main className="flex flex-col gap-8 items-start mx-auto sm:px-6 lg:px-8 py-8"> <div className="relative flex flex-col gap-8 items-start mx-auto sm:px-6 lg:px-8 py-8">
<header className="flex flex-col items-center h-fit w-full gap-3"> <header className="flex flex-col items-center h-fit w-full gap-3">
<h1 className="font-satoshi-black text-3xl">Leaderboards</h1> <h1 className="font-satoshi-black text-3xl">Leaderboards</h1>
{loading ? ( {loading ? (
@ -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="fixed bottom-19 bg-linear-to-br from-purple-500 to-purple-600 w-full 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,14 +348,25 @@ 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>
</> </>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</main> </div>
); );
}; };

View File

@ -14,14 +14,18 @@ export function StudentLayout() {
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="min-h-screen flex"> <div className="flex min-h-screen w-full overflow-x-hidden">
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<AppSidebar /> <AppSidebar />
<main className="flex-1">
<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"
}` }`
} }

View File

@ -13,7 +13,6 @@ import {
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useExamConfigStore } from "../../../stores/useExamConfigStore"; import { useExamConfigStore } from "../../../stores/useExamConfigStore";
import { useSatExam } from "../../../stores/useSatExam";
export const Pretest = () => { export const Pretest = () => {
const { setSheetId, setMode, storeDuration, setQuestionCount } = const { setSheetId, setMode, storeDuration, setQuestionCount } =
@ -76,7 +75,7 @@ export const Pretest = () => {
}, [carouselApi]); }, [carouselApi]);
return ( return (
<main className="max-w-full mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-6"> <div className="p-8 space-y-6">
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-4xl font-satoshi-bold">{practiceSheet?.title}</h1> <h1 className="text-4xl font-satoshi-bold">{practiceSheet?.title}</h1>
<p className="text-lg font-satoshi text-gray-700"> <p className="text-lg font-satoshi text-gray-700">
@ -120,8 +119,8 @@ export const Pretest = () => {
</div> </div>
</section> </section>
)} )}
<Carousel setApi={setCarouselApi}> <Carousel className="" setApi={setCarouselApi}>
<CarouselContent className=""> <CarouselContent>
{practiceSheet ? ( {practiceSheet ? (
practiceSheet.modules.length > 0 ? ( practiceSheet.modules.length > 0 ? (
practiceSheet.modules.map((module, index) => ( practiceSheet.modules.map((module, index) => (
@ -133,7 +132,7 @@ export const Pretest = () => {
<p className="text-lg font-satoshi text-gray-700"> <p className="text-lg font-satoshi text-gray-700">
{module.title} {module.title}
</p> </p>
<section className="flex justify-between"> <section className="grid grid-cols-3 gap-6 sm:grid-cols-3">
<div className="flex flex-col justify-center items-center gap-4"> <div className="flex flex-col justify-center items-center gap-4">
<div className="w-fit bg-cyan-100 p-2 rounded-full"> <div className="w-fit bg-cyan-100 p-2 rounded-full">
<Clock size={30} color="oklch(60.9% 0.126 221.723)" /> <Clock size={30} color="oklch(60.9% 0.126 221.723)" />
@ -202,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>
))} ))}
@ -217,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 ? (
@ -228,6 +227,6 @@ export const Pretest = () => {
</div> </div>
)} )}
</Button> </Button>
</main> </div>
); );
}; };

View File

@ -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 — 0100 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

View File

@ -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();

View File

@ -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;
};
}

View File

@ -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;

View File

@ -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);