web #1
@ -31,6 +31,7 @@
|
||||
"react-router-dom": "^7.12.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vaul": "^1.1.2",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -71,6 +71,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18
|
||||
vaul:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
zustand:
|
||||
specifier: ^5.0.9
|
||||
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
|
||||
@ -2221,6 +2224,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
vaul@1.1.2:
|
||||
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
vite@7.3.0:
|
||||
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@ -4385,6 +4394,15 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
|
||||
@ -11,26 +11,187 @@ type Props = {
|
||||
level: number;
|
||||
};
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap');
|
||||
|
||||
.clp-wrap {
|
||||
width: 100%;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
}
|
||||
|
||||
/* Outer card — full width */
|
||||
.clp-card {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border: 2.5px solid #f3f4f6;
|
||||
border-radius: 24px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Top row: level badge + XP gained chip */
|
||||
.clp-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.clp-level-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.clp-level-bubble {
|
||||
width: 52px; height: 52px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #c084fc, #a855f7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 0 #7e22ce44;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clp-level-num {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
color: white;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.clp-level-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.clp-level-word {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.clp-level-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
color: #1e1b4b;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* XP gained chip */
|
||||
.clp-xp-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: #fff7ed;
|
||||
border: 2px solid #fed7aa;
|
||||
border-radius: 100px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
/* Bar section */
|
||||
.clp-bar-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.clp-bar-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.clp-bar-track {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clp-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 100px;
|
||||
background: linear-gradient(90deg, #c084fc, #f97316);
|
||||
transition: width 1.2s cubic-bezier(0.4,0,0.2,1);
|
||||
}
|
||||
|
||||
/* XP total */
|
||||
.clp-xp-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #9ca3af;
|
||||
animation: clpFadeUp 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
|
||||
.clp-xp-pill .xp-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #f97316;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Level-up banner */
|
||||
.clp-levelup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: #fdf4ff;
|
||||
border: 2.5px solid #e9d5ff;
|
||||
border-radius: 14px;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 900;
|
||||
color: #9333ea;
|
||||
animation: clpPop 0.45s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
box-shadow: 0 4px 12px rgba(147,51,234,0.1);
|
||||
}
|
||||
|
||||
@keyframes clpPop {
|
||||
from { opacity:0; transform: scale(0.8); }
|
||||
to { opacity:1; transform: scale(1); }
|
||||
}
|
||||
@keyframes clpFadeUp {
|
||||
from { opacity:0; transform: translateY(6px); }
|
||||
to { opacity:1; transform: translateY(0); }
|
||||
}
|
||||
`;
|
||||
|
||||
export const CircularLevelProgress = ({
|
||||
size = 300,
|
||||
strokeWidth = 16,
|
||||
previousXP,
|
||||
gainedXP,
|
||||
levelMinXP,
|
||||
levelMaxXP,
|
||||
level,
|
||||
}: Props) => {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const levelRange = levelMaxXP - levelMinXP;
|
||||
|
||||
const normalize = (xp: number) =>
|
||||
Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange;
|
||||
|
||||
const [progress, setProgress] = useState(normalize(previousXP));
|
||||
const [barProgress, setBarProgress] = useState(normalize(previousXP));
|
||||
const [currentLevel, setCurrentLevel] = useState(level);
|
||||
const [showLevelUp, setShowLevelUp] = useState(false);
|
||||
const [showThresholdText, setShowThresholdText] = useState(false);
|
||||
const [showXPTotal, setShowXPTotal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrame: number;
|
||||
@ -38,28 +199,23 @@ export const CircularLevelProgress = ({
|
||||
|
||||
const availableXP = previousXP + gainedXP;
|
||||
const crossesLevel = availableXP >= levelMaxXP;
|
||||
|
||||
const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP);
|
||||
|
||||
const phase1Target = crossesLevel ? 1 : normalize(availableXP);
|
||||
const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0;
|
||||
|
||||
const duration = 1200;
|
||||
|
||||
const animatePhase1 = (timestamp: number) => {
|
||||
if (!start) start = timestamp;
|
||||
const t = Math.min((timestamp - start) / duration, 1);
|
||||
|
||||
setProgress(
|
||||
setBarProgress(
|
||||
normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
|
||||
);
|
||||
|
||||
if (t < 1) {
|
||||
animationFrame = requestAnimationFrame(animatePhase1);
|
||||
} else if (crossesLevel) {
|
||||
setShowLevelUp(true);
|
||||
setTimeout(startPhase2, 1200);
|
||||
} else {
|
||||
setShowThresholdText(true);
|
||||
setShowXPTotal(true);
|
||||
}
|
||||
};
|
||||
|
||||
@ -67,78 +223,77 @@ export const CircularLevelProgress = ({
|
||||
start = null;
|
||||
setShowLevelUp(false);
|
||||
setCurrentLevel((l) => l + 1);
|
||||
setProgress(0);
|
||||
|
||||
setBarProgress(0);
|
||||
const target = Math.min(leftoverXP / levelRange, 1);
|
||||
|
||||
const animatePhase2 = (timestamp: number) => {
|
||||
if (!start) start = timestamp;
|
||||
const t = Math.min((timestamp - start) / duration, 1);
|
||||
|
||||
setProgress(t * target);
|
||||
|
||||
setBarProgress(t * target);
|
||||
if (t < 1) {
|
||||
animationFrame = requestAnimationFrame(animatePhase2);
|
||||
} else {
|
||||
setShowThresholdText(true);
|
||||
setShowXPTotal(true);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animatePhase2);
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animatePhase1);
|
||||
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, []);
|
||||
|
||||
const offset = circumference * (1 - progress);
|
||||
const barPct = Math.round(barProgress * 100);
|
||||
const totalXP = previousXP + gainedXP;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
<div className="clp-wrap">
|
||||
<style>{STYLES}</style>
|
||||
{showLevelUp && <ConfettiBurst />}
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="oklch(94.6% 0.033 307.174)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="oklch(62.7% 0.265 303.9)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="absolute text-[100px] font-satoshi-bold flex flex-col items-center">
|
||||
{currentLevel}
|
||||
<div className="clp-card">
|
||||
{/* Top row */}
|
||||
<div className="clp-top-row">
|
||||
<div className="clp-level-badge">
|
||||
<div className="clp-level-bubble">
|
||||
<span className="clp-level-num">{currentLevel}</span>
|
||||
</div>
|
||||
<div className="clp-level-text">
|
||||
<span className="clp-level-word">Current Level</span>
|
||||
<span className="clp-level-title">Level {currentLevel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showThresholdText && (
|
||||
<span className="text-xl font-satoshi-medium text-gray-500 animate-fade-in">
|
||||
Total XP: {previousXP + gainedXP}
|
||||
</span>
|
||||
)}
|
||||
<div className="clp-xp-chip">⚡ +{gainedXP} XP</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="clp-bar-wrap">
|
||||
<div className="clp-bar-labels">
|
||||
<span>{levelMinXP} XP</span>
|
||||
<span>{barPct}%</span>
|
||||
<span>{levelMaxXP} XP</span>
|
||||
</div>
|
||||
<div className="clp-bar-track">
|
||||
<div className="clp-bar-fill" style={{ width: `${barPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer state */}
|
||||
{showLevelUp && (
|
||||
<span className="text-xl font-satoshi-medium text-purple-600 animate-fade-in">
|
||||
🎉 You leveled up!
|
||||
</span>
|
||||
<div className="clp-levelup">
|
||||
🎉 You leveled up! Welcome to Level {currentLevel}!
|
||||
</div>
|
||||
)}
|
||||
{showXPTotal && !showLevelUp && (
|
||||
<div className="clp-xp-pill">
|
||||
<div className="xp-dot" />
|
||||
Total XP:{" "}
|
||||
<strong style={{ color: "#1e1b4b", marginLeft: 3 }}>
|
||||
{totalXP}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "../components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader } from "../components/ui/dialog";
|
||||
import { api } from "../utils/api";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { Loader, X } from "lucide-react";
|
||||
|
||||
interface LessonModalProps {
|
||||
lessonId: string | null;
|
||||
@ -15,6 +10,107 @@ interface LessonModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
/* Override Dialog defaults */
|
||||
.lm-content {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
background: #fffbf4;
|
||||
border: 2.5px solid #f3f4f6;
|
||||
border-radius: 28px !important;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
max-width: 680px;
|
||||
width: calc(100vw - 2rem);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.12);
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header bar */
|
||||
.lm-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
.lm-title-wrap { display:flex;flex-direction:column;gap:0.2rem; flex:1; }
|
||||
.lm-eyebrow {
|
||||
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: #a855f7;
|
||||
}
|
||||
.lm-title {
|
||||
font-size: 1.2rem; font-weight: 900; color: #1e1b4b;
|
||||
letter-spacing: -0.01em; line-height: 1.25;
|
||||
}
|
||||
.lm-close-btn {
|
||||
width: 34px; height: 34px; flex-shrink: 0;
|
||||
border-radius: 50%; border: 2.5px solid #f3f4f6;
|
||||
background: white; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.lm-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
|
||||
|
||||
/* Scrollable body */
|
||||
.lm-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Video player */
|
||||
.lm-video {
|
||||
width: 100%; border-radius: 18px;
|
||||
aspect-ratio: 16/9; background: #1e1b4b;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Topic chip */
|
||||
.lm-topic-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
background: #f3e8ff; border: 2px solid #e9d5ff;
|
||||
border-radius: 100px; padding: 0.3rem 0.8rem;
|
||||
font-size: 0.7rem; font-weight: 800;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: #9333ea; width: fit-content;
|
||||
}
|
||||
|
||||
/* Description & content cards */
|
||||
.lm-card {
|
||||
background: white; border: 2.5px solid #f3f4f6;
|
||||
border-radius: 18px; padding: 1rem 1.1rem;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
|
||||
}
|
||||
.lm-card-label {
|
||||
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: #9ca3af; margin-bottom: 0.4rem;
|
||||
}
|
||||
.lm-card-text {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.88rem; font-weight: 600; color: #374151;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.lm-loading {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 0.75rem;
|
||||
padding: 3rem 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
.lm-loading-spinner { animation: lmSpin 0.8s linear infinite; }
|
||||
@keyframes lmSpin { to { transform: rotate(360deg); } }
|
||||
.lm-loading-text {
|
||||
font-size: 0.85rem; font-weight: 700; color: #9ca3af;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LessonModal = ({
|
||||
lessonId,
|
||||
open,
|
||||
@ -26,21 +122,15 @@ export const LessonModal = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !lessonId || !user) return;
|
||||
|
||||
const fetchLesson = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const authStorage = localStorage.getItem("auth-storage");
|
||||
if (!authStorage) return;
|
||||
|
||||
const parsed = JSON.parse(authStorage) as {
|
||||
state?: { token?: string };
|
||||
};
|
||||
|
||||
const token = parsed.state?.token;
|
||||
const {
|
||||
state: { token },
|
||||
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||
if (!token) return;
|
||||
|
||||
const response = await api.fetchLessonById(token, lessonId);
|
||||
setLesson(response);
|
||||
} catch (err) {
|
||||
@ -49,40 +139,73 @@ export const LessonModal = ({
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLesson();
|
||||
}, [open, lessonId, user]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
{loading && (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Loading lesson...
|
||||
</div>
|
||||
)}
|
||||
<DialogHeader>
|
||||
<DialogTitle>{lesson ? lesson.title : "Lesson details"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<style>{STYLES}</style>
|
||||
<DialogContent className="lm-content" showCloseButton={false}>
|
||||
<DialogHeader style={{ display: "none" }} />
|
||||
|
||||
{!loading && lesson && (
|
||||
<div className="space-y-4">
|
||||
{lesson.video_url && (
|
||||
<video
|
||||
src={lesson.video_url}
|
||||
controls
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
)}
|
||||
<h2 className="font-satoshi-bold text-xl">
|
||||
{lesson ? lesson.title : "Lesson details"}
|
||||
{/* Header */}
|
||||
<div className="lm-header">
|
||||
<div className="lm-title-wrap">
|
||||
<span className="lm-eyebrow">📖 Lesson</span>
|
||||
<h2 className="lm-title">
|
||||
{loading ? "Loading..." : (lesson?.title ?? "Lesson details")}
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{lesson.description}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{lesson.content}</p>
|
||||
</div>
|
||||
<button className="lm-close-btn" onClick={() => onOpenChange(false)}>
|
||||
<X size={16} color="#6b7280" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{loading ? (
|
||||
<div className="lm-loading">
|
||||
<Loader size={28} color="#a855f7" className="lm-loading-spinner" />
|
||||
<p className="lm-loading-text">Loading lesson...</p>
|
||||
</div>
|
||||
) : (
|
||||
lesson && (
|
||||
<div className="lm-body">
|
||||
{lesson.video_url && (
|
||||
<video src={lesson.video_url} controls className="lm-video" />
|
||||
)}
|
||||
|
||||
{lesson.topic?.name && (
|
||||
<div>
|
||||
<span className="lm-topic-chip">
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "#a855f7",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{lesson.topic.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.description && (
|
||||
<div className="lm-card">
|
||||
<p className="lm-card-label">About this lesson</p>
|
||||
<p className="lm-card-text">{lesson.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.content && (
|
||||
<div className="lm-card">
|
||||
<p className="lm-card-label">Content</p>
|
||||
<p className="lm-card-text">{lesson.content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
133
src/components/ui/drawer.tsx
Normal file
133
src/components/ui/drawer.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@ -120,7 +120,7 @@ export const Login = () => {
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !email || !password}
|
||||
className="w-full bg-linear-to-br from-purple-500 to-purple-600 text-white py-3 rounded-2xl hover:bg-purple-700 transition font-medium disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center font-satoshi hover:cursor-pointer"
|
||||
className="w-full bg-linear-to-br from-indigo-500 to-indigo-600 text-white py-3 rounded-2xl hover:bg-indigo-700 transition font-medium disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center font-satoshi hover:cursor-pointer"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
|
||||
@ -1,23 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsTrigger,
|
||||
TabsList,
|
||||
TabsContent,
|
||||
} from "../../components/ui/tabs";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { CheckCircle, Flame, Search, Zap } from "lucide-react";
|
||||
import { CheckCircle, Flame, Gauge, Play, Search } from "lucide-react";
|
||||
import { api } from "../../utils/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import type { PracticeSheet } from "../../types/sheet";
|
||||
import { formatStatus } from "../../lib/utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@ -29,7 +14,297 @@ import {
|
||||
AvatarImage,
|
||||
} from "../../components/ui/avatar";
|
||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTrigger,
|
||||
} from "../../components/ui/drawer";
|
||||
|
||||
// ─── Shared blob/dot background (same as break/results screens) ────────────────
|
||||
const DOTS = [
|
||||
{ size: 12, color: "#f97316", top: "8%", left: "6%", delay: "0s" },
|
||||
{ size: 8, color: "#a855f7", top: "22%", left: "2%", delay: "1s" },
|
||||
{ size: 10, color: "#22c55e", top: "55%", left: "4%", delay: "0.5s" },
|
||||
{ size: 14, color: "#3b82f6", top: "10%", right: "5%", delay: "1.5s" },
|
||||
{ size: 8, color: "#f43f5e", top: "40%", right: "3%", delay: "0.8s" },
|
||||
{ size: 10, color: "#eab308", top: "70%", right: "7%", delay: "0.3s" },
|
||||
];
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
.home-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Satoshi', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Blobs ── */
|
||||
.h-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
|
||||
.h-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:hWobble1 14s ease-in-out infinite; }
|
||||
.h-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:hWobble2 16s ease-in-out infinite; }
|
||||
.h-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:hWobble1 18s ease-in-out infinite reverse; }
|
||||
.h-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:hWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes hWobble1 {
|
||||
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
|
||||
}
|
||||
@keyframes hWobble2 {
|
||||
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
|
||||
}
|
||||
|
||||
/* ── Floating dots ── */
|
||||
.h-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:hFloat 6s ease-in-out infinite; }
|
||||
@keyframes hFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-14px) rotate(180deg);}
|
||||
}
|
||||
|
||||
/* ── Inner scroll container ── */
|
||||
.home-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 580px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.home-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
animation: hPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
.home-header-left { display:flex;align-items:center;gap:0.75rem; }
|
||||
.home-user-name {
|
||||
font-size: 1.1rem; font-weight: 900; color: #1e1b4b; line-height:1.1;
|
||||
}
|
||||
.home-user-role {
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing:0.08em;
|
||||
text-transform: uppercase; color: #a855f7;
|
||||
}
|
||||
.home-header-right { display:flex;align-items:center;gap:0.6rem; }
|
||||
|
||||
/* Header action chips */
|
||||
.h-chip {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
background: white; border: 2.5px solid #f3f4f6;
|
||||
border-radius: 100px; padding: 0.5rem 0.9rem;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.06);
|
||||
cursor: pointer; font-size:0.85rem; font-weight:800;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.h-chip:hover { transform:translateY(-2px);box-shadow:0 6px 14px rgba(0,0,0,0.08); }
|
||||
.h-chip.streak { border-color:#fecaca; background:#fff5f5; color:#ef4444; }
|
||||
.h-chip.score { border-color:#d9f99d; background:#f7ffe4; color:#65a30d; }
|
||||
|
||||
/* ── Section titles ── */
|
||||
.h-section-title {
|
||||
font-size: 1.2rem; font-weight: 900; color: #1e1b4b;
|
||||
letter-spacing: -0.01em; margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Search bar ── */
|
||||
.h-search-wrap {
|
||||
position: relative;
|
||||
animation: hPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) 0.05s both;
|
||||
}
|
||||
.h-search-input {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 0.85rem 1rem 0.85rem 2.8rem;
|
||||
background: white; border: 2.5px solid #f3f4f6;
|
||||
border-radius: 18px;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.9rem; font-weight: 700; color: #9ca3af;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.05);
|
||||
cursor: pointer; outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.h-search-input:focus { border-color:#c084fc; box-shadow:0 4px 20px rgba(192,132,252,0.15); }
|
||||
.h-search-icon {
|
||||
position: absolute; left: 0.9rem; top: 50%;
|
||||
transform: translateY(-50%); pointer-events:none;
|
||||
}
|
||||
|
||||
/* ── In-progress card ── */
|
||||
.h-inprogress-card {
|
||||
background: white;
|
||||
border: 2.5px solid #c4b5fd;
|
||||
border-radius: 22px;
|
||||
padding: 1.1rem 1.25rem;
|
||||
box-shadow: 0 4px 16px rgba(167,139,250,0.12);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 1rem; cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.h-inprogress-card:hover { transform:translateY(-2px);box-shadow:0 8px 24px rgba(167,139,250,0.2); }
|
||||
.h-inprogress-info { display:flex;flex-direction:column;gap:0.25rem;flex:1;min-width:0; }
|
||||
.h-inprogress-title {
|
||||
font-size: 0.95rem; font-weight: 900; color: #1e1b4b;
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||||
}
|
||||
.h-inprogress-badge {
|
||||
font-size: 0.65rem; font-weight: 800; letter-spacing:0.1em;
|
||||
text-transform:uppercase; color:#a855f7;
|
||||
background:#f3e8ff; border-radius:100px; padding:0.2rem 0.6rem;
|
||||
width: fit-content;
|
||||
}
|
||||
.h-play-btn {
|
||||
width: 44px; height: 44px; border-radius: 50%; border: none; cursor: pointer;
|
||||
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-shadow: 0 4px 0 #5b21b6aa;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
flex-shrink:0;
|
||||
}
|
||||
.h-play-btn:hover { transform:translateY(-2px);box-shadow:0 6px 0 #5b21b6aa; }
|
||||
.h-play-btn:active { transform:translateY(2px);box-shadow:0 2px 0 #5b21b6aa; }
|
||||
|
||||
/* ── Empty state ── */
|
||||
.h-empty {
|
||||
background:white; border:2.5px dashed #e5e7eb; border-radius:22px;
|
||||
padding: 1.75rem; text-align:center;
|
||||
font-size:0.9rem; font-weight:700; color:#9ca3af;
|
||||
}
|
||||
.h-empty-emoji { font-size:2rem; display:block; margin-bottom:0.5rem; }
|
||||
|
||||
/* ── Tabs ── */
|
||||
.h-tabs-list {
|
||||
display:flex; border-bottom: 2px solid #f3f4f6;
|
||||
gap: 0; margin-bottom:1rem;
|
||||
}
|
||||
.h-tab-btn {
|
||||
flex:1; padding:0.65rem 0; text-align:center;
|
||||
font-family:'Nunito',sans-serif; font-size:0.82rem; font-weight:800;
|
||||
color:#9ca3af; background:transparent; border:none; border-bottom: 3px solid transparent;
|
||||
margin-bottom:-2px; cursor:pointer;
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.h-tab-btn.active { color:#1e1b4b; border-bottom-color:#a855f7; }
|
||||
|
||||
/* ── Practice sheet card ── */
|
||||
.h-sheet-grid {
|
||||
display:grid; gap:0.85rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media(min-width:520px){ .h-sheet-grid { grid-template-columns:1fr 1fr; } }
|
||||
|
||||
.h-sheet-card {
|
||||
background:white; border:2.5px solid #f3f4f6; border-radius:22px;
|
||||
padding:1.1rem; box-shadow:0 4px 14px rgba(0,0,0,0.04);
|
||||
display:flex; flex-direction:column; gap:0.6rem;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.h-sheet-card:hover { transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,0.07); }
|
||||
|
||||
.h-sheet-title { font-size:0.95rem;font-weight:900;color:#1e1b4b;line-height:1.2; }
|
||||
.h-sheet-desc { font-size:0.78rem;font-weight:600;color:#9ca3af;font-family:'Nunito Sans',sans-serif; }
|
||||
|
||||
.h-sheet-meta {
|
||||
display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.4rem;
|
||||
}
|
||||
.h-status-pill {
|
||||
font-size:0.65rem;font-weight:800;letter-spacing:0.08em;text-transform:uppercase;
|
||||
border-radius:100px;padding:0.25rem 0.65rem;
|
||||
}
|
||||
.h-status-pill.inprogress { background:#f3e8ff;color:#9333ea; }
|
||||
.h-status-pill.notstarted { background:#f3f4f6;color:#6b7280; }
|
||||
.h-status-pill.completed { background:#dcfce7;color:#16a34a; }
|
||||
|
||||
.h-modules-badge {
|
||||
font-size:0.65rem;font-weight:800;
|
||||
background:#ede9fe;color:#7c3aed;border-radius:100px;padding:0.25rem 0.65rem;
|
||||
}
|
||||
.h-time-badge {
|
||||
font-size:0.72rem;font-weight:700;color:#9ca3af;display:flex;align-items:center;gap:0.3rem;
|
||||
}
|
||||
|
||||
.h-start-btn {
|
||||
width:100%;margin-top:auto;
|
||||
background:#f97316;color:white;border:none;border-radius:100px;
|
||||
padding:0.75rem;font-family:'Nunito',sans-serif;
|
||||
font-size:0.9rem;font-weight:800;cursor:pointer;
|
||||
box-shadow:0 4px 0 #c2560e,0 6px 16px rgba(249,115,22,0.2);
|
||||
transition:transform 0.1s ease,box-shadow 0.1s ease;
|
||||
}
|
||||
.h-start-btn:hover { transform:translateY(-2px);box-shadow:0 6px 0 #c2560e,0 10px 20px rgba(249,115,22,0.25); }
|
||||
.h-start-btn:active { transform:translateY(2px); box-shadow:0 2px 0 #c2560e,0 3px 8px rgba(249,115,22,0.15); }
|
||||
|
||||
/* ── Tips section ── */
|
||||
.h-tips-list { display:flex;flex-direction:column;gap:0.6rem; }
|
||||
.h-tip-row {
|
||||
display:flex;align-items:flex-start;gap:0.65rem;
|
||||
background:white;border:2.5px solid #f3f4f6;border-radius:16px;
|
||||
padding:0.75rem 1rem;box-shadow:0 2px 8px rgba(0,0,0,0.03);
|
||||
}
|
||||
.h-tip-icon { flex-shrink:0;margin-top:1px; }
|
||||
.h-tip-text { font-size:0.85rem;font-weight:700;color:#374151;line-height:1.4; }
|
||||
|
||||
@keyframes hPopIn {
|
||||
from{opacity:0;transform:scale(0.92) translateY(10px);}
|
||||
to{opacity:1;transform:scale(1) translateY(0);}
|
||||
}
|
||||
.h-anim { animation: hPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.h-anim-1 { animation-delay:0.05s; }
|
||||
.h-anim-2 { animation-delay:0.1s; }
|
||||
.h-anim-3 { animation-delay:0.15s; }
|
||||
.h-anim-4 { animation-delay:0.2s; }
|
||||
.h-anim-5 { animation-delay:0.25s; }
|
||||
`;
|
||||
|
||||
// ─── Sheet card ───────────────────────────────────────────────────────────────
|
||||
const SheetCard = ({
|
||||
sheet,
|
||||
onStart,
|
||||
}: {
|
||||
sheet: PracticeSheet;
|
||||
onStart: (id: string) => void;
|
||||
}) => {
|
||||
const statusClass =
|
||||
sheet.user_status === "IN_PROGRESS"
|
||||
? "inprogress"
|
||||
: sheet.user_status === "COMPLETED"
|
||||
? "completed"
|
||||
: "notstarted";
|
||||
|
||||
return (
|
||||
<div className="h-sheet-card">
|
||||
<p className="h-sheet-title">{sheet.title}</p>
|
||||
{sheet.description && <p className="h-sheet-desc">{sheet.description}</p>}
|
||||
<div className="h-sheet-meta">
|
||||
<span className={`h-status-pill ${statusClass}`}>
|
||||
{formatStatus(sheet.user_status)}
|
||||
</span>
|
||||
<span className="h-modules-badge">
|
||||
📚 {sheet.modules_count} modules
|
||||
</span>
|
||||
</div>
|
||||
<p className="h-time-badge">⏱️ {sheet.time_limit} min</p>
|
||||
<button className="h-start-btn" onClick={() => onStart(sheet.id)}>
|
||||
{sheet.user_status === "COMPLETED" ? "Retry →" : "Start →"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Tips data ────────────────────────────────────────────────────────────────
|
||||
const TIPS = [
|
||||
"Practice regularly with official SAT materials",
|
||||
"Review your mistakes and learn from them",
|
||||
"Focus on your weak areas first",
|
||||
"Take full-length practice tests",
|
||||
"Get plenty of rest before test day",
|
||||
];
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export const Home = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const navigate = useNavigate();
|
||||
@ -39,358 +314,244 @@ export const Home = () => {
|
||||
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
||||
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
|
||||
const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"all" | "NOT_STARTED" | "COMPLETED"
|
||||
>("all");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const sortPracticeSheets = (sheets: PracticeSheet[]) => {
|
||||
const notStarted = sheets.filter(
|
||||
(sheet) => sheet.user_status === "NOT_STARTED",
|
||||
const sort = (sheets: PracticeSheet[]) => {
|
||||
setNotStartedSheets(
|
||||
sheets.filter((s) => s.user_status === "NOT_STARTED"),
|
||||
);
|
||||
const inProgress = sheets.filter(
|
||||
(sheet) => sheet.user_status === "IN_PROGRESS",
|
||||
setInProgressSheets(
|
||||
sheets.filter((s) => s.user_status === "IN_PROGRESS"),
|
||||
);
|
||||
const completed = sheets.filter(
|
||||
(sheet) => sheet.user_status === "COMPLETED",
|
||||
);
|
||||
|
||||
setNotStartedSheets(notStarted);
|
||||
setInProgressSheets(inProgress);
|
||||
setCompletedSheets(completed);
|
||||
setCompletedSheets(sheets.filter((s) => s.user_status === "COMPLETED"));
|
||||
};
|
||||
|
||||
const fetchPracticeSheets = async () => {
|
||||
const fetch = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const authStorage = localStorage.getItem("auth-storage");
|
||||
if (!authStorage) {
|
||||
console.error("authStorage not found in local storage");
|
||||
return;
|
||||
}
|
||||
if (!authStorage) return;
|
||||
const {
|
||||
state: { token },
|
||||
} = JSON.parse(authStorage);
|
||||
if (!token) {
|
||||
console.error("Token not found in authStorage");
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
const sheets = await api.getPracticeSheets(token, 1, 10);
|
||||
setPracticeSheets(sheets.data);
|
||||
sortPracticeSheets(sheets.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching practice sheets:", error);
|
||||
sort(sheets.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPracticeSheets();
|
||||
fetch();
|
||||
}, [user]);
|
||||
|
||||
const handleStartPracticeSheet = (sheetId: string) => {
|
||||
navigate(`/student/practice/${sheetId}`);
|
||||
};
|
||||
const handleStart = (id: string) => navigate(`/student/practice/${id}`);
|
||||
|
||||
const tabSheets =
|
||||
activeTab === "all"
|
||||
? practiceSheets
|
||||
: activeTab === "NOT_STARTED"
|
||||
? notStartedSheets
|
||||
: completedSheets;
|
||||
|
||||
const greeting =
|
||||
new Date().getHours() < 12
|
||||
? "Good morning"
|
||||
: new Date().getHours() < 17
|
||||
? "Good afternoon"
|
||||
: "Good evening";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-12">
|
||||
<header className="flex items-center gap-3 justify-between">
|
||||
<div className="flex gap-3">
|
||||
<Avatar className="w-12 h-12">
|
||||
<div className="home-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="h-blob h-blob-1" />
|
||||
<div className="h-blob h-blob-2" />
|
||||
<div className="h-blob h-blob-3" />
|
||||
<div className="h-blob h-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-dot"
|
||||
style={
|
||||
{
|
||||
width: d.size,
|
||||
height: d.size,
|
||||
background: d.color,
|
||||
top: d.top,
|
||||
left: d.left,
|
||||
right: d.right,
|
||||
animationDelay: d.delay,
|
||||
animationDuration: `${3.5 + i * 0.4}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="home-inner">
|
||||
{/* ── Header ── */}
|
||||
<header className="home-header">
|
||||
<div className="home-header-left">
|
||||
<Avatar style={{ width: 48, height: 48 }}>
|
||||
<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
|
||||
style={{
|
||||
fontWeight: 900,
|
||||
fontSize: "1.1rem",
|
||||
color: "white",
|
||||
textTransform: "uppercase",
|
||||
background: "linear-gradient(135deg,#a855f7,#7c3aed)",
|
||||
}}
|
||||
>
|
||||
{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 ">
|
||||
<div className="space-y-1">
|
||||
<p className="home-user-name">
|
||||
{greeting}, {user?.name?.split(" ")[0] || "Student"}
|
||||
</p>
|
||||
<p className="home-user-role">
|
||||
{user?.role === "STUDENT"
|
||||
? "Student"
|
||||
: user?.role === "ADMIN"
|
||||
? "Admin"
|
||||
: "Taecher"}
|
||||
</h4>
|
||||
: "Teacher"}
|
||||
</p>
|
||||
</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 className="home-header-right">
|
||||
{/* Streak chip */}
|
||||
<div className="h-chip streak">
|
||||
<Flame size={18} style={{ fill: "#fca5a5" }} />
|
||||
<span>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>
|
||||
{/* Score chip */}
|
||||
<Drawer direction="top">
|
||||
<DrawerTrigger asChild>
|
||||
<div className="h-chip score">
|
||||
<Gauge size={18} />
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<PredictedScoreCard />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
</header>
|
||||
<PredictedScoreCard />
|
||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||
What are you looking for?
|
||||
</h1>
|
||||
<section className="relative w-full">
|
||||
|
||||
{/* ── Search ── */}
|
||||
<div className="h-search-wrap h-anim h-anim-1">
|
||||
<span className="h-search-icon">
|
||||
<Search size={18} color="#9ca3af" />
|
||||
</span>
|
||||
<input
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
className="h-search-input"
|
||||
placeholder="Search practice sheets..."
|
||||
readOnly
|
||||
className="font-satoshi w-full pl-10 pr-4 py-3 border border-gray-300 rounded-2xl shadow-sm cursor-pointer"
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Search size={22} color="gray" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||
Pick up where you left off
|
||||
</h1>
|
||||
{/* ── In progress ── */}
|
||||
<section className="h-anim h-anim-2">
|
||||
<p className="h-section-title">📌 Pick up where you left off</p>
|
||||
{inProgressSheets.length > 0 ? (
|
||||
inProgressSheets.map((sheet) => (
|
||||
<Card
|
||||
key={sheet?.id}
|
||||
className="rounded-4xl border bg-indigo-50/70 border-indigo-500"
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.65rem",
|
||||
}}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-satoshi-medium text-xl">
|
||||
{sheet?.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-satoshi">
|
||||
{sheet?.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-between">
|
||||
<p className="font-satoshi text-sm border px-2 rounded-full bg-indigo-500 text-white py-1">
|
||||
{formatStatus(sheet?.user_status)}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide"
|
||||
{inProgressSheets.map((sheet) => (
|
||||
<div
|
||||
key={sheet.id}
|
||||
className="h-inprogress-card"
|
||||
onClick={() => handleStart(sheet.id)}
|
||||
>
|
||||
{sheet?.modules_count} modules
|
||||
</Badge>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<p className="font-satoshi text-gray-700">
|
||||
{sheet?.time_limit} minutes
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||
variant="outline"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className="flex items-center justify-center py-4 rounded-4xl">
|
||||
<h2 className="text-center font-satoshi text-lg text-gray-800">
|
||||
You don't have any practice sheets in progress. Why not start one?
|
||||
</h2>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
<section className="w-full">
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<TabsList className="bg-transparent p-0 w-full">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
All
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="NOT_STARTED"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
Not Started
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="COMPLETED"
|
||||
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
Completed
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all" className="pt-6">
|
||||
<div className="gap-6 flex flex-col md:grid md:grid-cols-2">
|
||||
{practiceSheets.length > 0 ? (
|
||||
practiceSheets.map((sheet) => (
|
||||
<Card key={sheet?.id} className="rounded-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-satoshi-medium text-xl">
|
||||
{sheet?.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-satoshi">
|
||||
{sheet?.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-between">
|
||||
<p className="font-satoshi text-gray-500">
|
||||
{formatStatus(sheet?.user_status)}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide"
|
||||
>
|
||||
{sheet?.modules_count} modules
|
||||
</Badge>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<p className="font-satoshi text-gray-700">
|
||||
{sheet?.time_limit} minutes
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={() => handleStartPracticeSheet(sheet?.id)}
|
||||
variant="outline"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 text-white"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-4 rounded-full">
|
||||
<h2 className="text-center font-satoshi text-lg text-gray-500">
|
||||
No Practice Sheets available.
|
||||
</h2>
|
||||
<div className="h-inprogress-info">
|
||||
<p className="h-inprogress-title">{sheet.title}</p>
|
||||
<span className="h-inprogress-badge">In Progress</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="h-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStart(sheet.id);
|
||||
}}
|
||||
>
|
||||
<Play size={18} color="white" fill="white" />
|
||||
</button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="NOT_STARTED" className="pt-6">
|
||||
<div className="gap-6 flex flex-col md:grid md:grid-cols-2">
|
||||
{notStartedSheets.map((sheet) => (
|
||||
<Card key={sheet?.id} className="rounded-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-satoshi-medium text-xl">
|
||||
{sheet?.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-satoshi">
|
||||
{sheet?.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-between">
|
||||
<p className="font-satoshi text-gray-700">Not Started</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide "
|
||||
>
|
||||
{sheet?.modules_count} modules
|
||||
</Badge>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<p className="font-satoshi text-gray-700">
|
||||
{sheet?.time_limit} minutes
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-3xl text-white"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="COMPLETED" className="pt-6">
|
||||
<div className="gap-6 flex flex-col md:grid md:grid-cols-2">
|
||||
{completedSheets.length > 0 ? (
|
||||
completedSheets.map((sheet) => (
|
||||
<Card key={sheet?.id} className="rounded-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-satoshi-medium text-xl">
|
||||
{sheet?.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-satoshi">
|
||||
{sheet?.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-between">
|
||||
<p className="font-satoshi text-gray-500">
|
||||
{formatStatus(sheet?.user_status)}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide"
|
||||
>
|
||||
{sheet?.modules_count} modules
|
||||
</Badge>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<p className="font-satoshi text-gray-700">
|
||||
{sheet?.time_limit} minutes
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-3xl text-white"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-4 rounded-full">
|
||||
<h2 className="text-center font-satoshi text-lg text-gray-500">
|
||||
You have not completed any practice sheets.
|
||||
</h2>
|
||||
<div className="h-empty">
|
||||
<span className="h-empty-emoji">🎯</span>
|
||||
No sheets in progress — start one below!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 ">
|
||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||
SAT Preparation Tips
|
||||
</h1>
|
||||
<section className="space-y-4 ">
|
||||
<div className="flex gap-2">
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">
|
||||
Practice regularly with official SAT materials
|
||||
</p>
|
||||
{/* ── All sheets with tabs ── */}
|
||||
<section className="h-anim h-anim-3">
|
||||
<p className="h-section-title">📋 Practice Sheets</p>
|
||||
|
||||
{/* Tab buttons */}
|
||||
<div className="h-tabs-list">
|
||||
{(["all", "NOT_STARTED", "COMPLETED"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`h-tab-btn${activeTab === tab ? " active" : ""}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab === "all"
|
||||
? "All"
|
||||
: tab === "NOT_STARTED"
|
||||
? "Not Started"
|
||||
: "Completed"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">
|
||||
Review your mistakes and learn from them
|
||||
</p>
|
||||
|
||||
{tabSheets.length > 0 ? (
|
||||
<div className="h-sheet-grid">
|
||||
{tabSheets.map((sheet) => (
|
||||
<SheetCard key={sheet.id} sheet={sheet} onStart={handleStart} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">Focus on your weak areas</p>
|
||||
) : (
|
||||
<div className="h-empty">
|
||||
<span className="h-empty-emoji">🔍</span>
|
||||
Nothing here yet!
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">
|
||||
Take full-length practice tests
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Tips ── */}
|
||||
<section className="h-anim h-anim-4">
|
||||
<p className="h-section-title">💡 SAT Prep Tips</p>
|
||||
<div className="h-tips-list">
|
||||
{TIPS.map((tip, i) => (
|
||||
<div key={i} className="h-tip-row">
|
||||
<CheckCircle size={18} color="#a855f7" className="h-tip-icon" />
|
||||
<p className="h-tip-text">{tip}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
||||
<p className="font-satoshi text-md">
|
||||
Get plenty of rest before the test day
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{isSearchOpen && (
|
||||
<SearchOverlay
|
||||
sheets={practiceSheets}
|
||||
@ -402,6 +563,6 @@ export const Home = () => {
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,188 +1,294 @@
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../components/ui/tabs";
|
||||
import { api } from "../../utils/api";
|
||||
import { type Lesson } from "../../types/lesson";
|
||||
import { LessonSkeleton } from "../../components/LessonSkeleton";
|
||||
import { LessonModal } from "../../components/LessonModal";
|
||||
import { BookOpen, Calculator } from "lucide-react";
|
||||
|
||||
const DOTS = [
|
||||
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
|
||||
{ size: 7, color: "#a855f7", top: "25%", left: "2%", delay: "1.2s" },
|
||||
{ size: 9, color: "#22c55e", top: "60%", left: "3%", delay: "0.6s" },
|
||||
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
|
||||
{ size: 7, color: "#f43f5e", top: "45%", right: "2%", delay: "0.9s" },
|
||||
{ size: 9, color: "#eab308", top: "75%", right: "5%", delay: "0.4s" },
|
||||
];
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
.ls-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ls-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
|
||||
.ls-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:lsWobble1 14s ease-in-out infinite; }
|
||||
.ls-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:lsWobble2 16s ease-in-out infinite; }
|
||||
.ls-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:lsWobble1 18s ease-in-out infinite reverse; }
|
||||
.ls-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:lsWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes lsWobble1 {
|
||||
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
|
||||
}
|
||||
@keyframes lsWobble2 {
|
||||
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
|
||||
}
|
||||
|
||||
.ls-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:lsFloat 7s ease-in-out infinite; }
|
||||
@keyframes lsFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-12px) rotate(180deg);}
|
||||
}
|
||||
|
||||
.ls-inner {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 680px; margin: 0 auto;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
display: flex; flex-direction: column; gap: 1.25rem;
|
||||
}
|
||||
|
||||
@keyframes lsPopIn {
|
||||
from { opacity:0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity:1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.ls-anim { animation: lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.ls-anim-1 { animation-delay: 0.05s; }
|
||||
.ls-anim-2 { animation-delay: 0.1s; }
|
||||
.ls-anim-3 { animation-delay: 0.15s; }
|
||||
|
||||
/* Header */
|
||||
.ls-header { animation: lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.ls-title { font-size: 1.8rem; font-weight: 900; color: #1e1b4b; letter-spacing: -0.02em; }
|
||||
.ls-sub {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.85rem; font-weight: 600; color: #9ca3af; margin-top: 0.25rem;
|
||||
line-height: 1.5; max-width: 420px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.ls-tabs-list {
|
||||
display: flex; gap: 0.5rem; margin-bottom: 1.25rem;
|
||||
}
|
||||
.ls-tab-btn {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.55rem 1.1rem; border-radius: 100px; cursor: pointer; border: none;
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.82rem; font-weight: 800;
|
||||
transition: all 0.2s ease;
|
||||
background: white; border: 2.5px solid #f3f4f6; color: #9ca3af;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.ls-tab-btn.active {
|
||||
background: #1e1b4b; border-color: #1e1b4b; color: white;
|
||||
box-shadow: 0 4px 0 #1e1b4b66;
|
||||
}
|
||||
.ls-tab-btn:not(.active):hover { border-color: #c4b5fd; color: #7c3aed; }
|
||||
|
||||
/* Lesson grid */
|
||||
.ls-grid {
|
||||
display: grid; gap: 0.85rem; grid-template-columns: 1fr;
|
||||
}
|
||||
@media(min-width: 480px) { .ls-grid { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
/* Lesson card */
|
||||
.ls-card {
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 22px;
|
||||
overflow: hidden; cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.04);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.ls-card:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,0.08); }
|
||||
.ls-card:active { transform: translateY(1px); box-shadow: 0 3px 8px rgba(0,0,0,0.05); }
|
||||
|
||||
.ls-card-thumb {
|
||||
width: 100%; aspect-ratio: 16/9; object-fit: cover;
|
||||
display: block; background: #f3f4f6;
|
||||
}
|
||||
.ls-card-body { padding: 0.9rem 1rem 1rem; display: flex; flex-direction: column; gap: 0.25rem; flex: 1; }
|
||||
.ls-card-title { font-size: 0.92rem; font-weight: 900; color: #1e1b4b; line-height: 1.3; }
|
||||
.ls-card-topic {
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.ls-card-topic.rw { color: #a855f7; }
|
||||
.ls-card-topic.math { color: #0891b2; }
|
||||
.ls-topic-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.ls-topic-dot.rw { background: #a855f7; }
|
||||
.ls-topic-dot.math { background: #0891b2; }
|
||||
|
||||
/* Empty / error */
|
||||
.ls-empty {
|
||||
grid-column: 1 / -1; text-align: center; padding: 3rem 1rem;
|
||||
background: white; border: 2.5px dashed #e5e7eb; border-radius: 22px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.ls-empty-emoji { font-size: 2.5rem; }
|
||||
.ls-empty-text { font-size: 0.9rem; font-weight: 700; color: #9ca3af; }
|
||||
|
||||
/* Skeleton shimmer override */
|
||||
.ls-skeleton-grid { display: grid; gap: 0.85rem; grid-template-columns: 1fr; }
|
||||
@media(min-width: 480px) { .ls-skeleton-grid { grid-template-columns: 1fr 1fr; } }
|
||||
`;
|
||||
|
||||
export const Lessons = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||
const [lessonLoading, setLessonlLoading] = useState(true);
|
||||
|
||||
const [lessonLoading, setLessonLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<"rw" | "math">("rw");
|
||||
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleLessonClick = (lessonId: string) => {
|
||||
setSelectedLessonId(lessonId);
|
||||
const handleLessonClick = (id: string) => {
|
||||
setSelectedLessonId(id);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllLessons = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setLessonlLoading(true);
|
||||
setLessonLoading(true);
|
||||
const authStorage = localStorage.getItem("auth-storage");
|
||||
if (!authStorage) return;
|
||||
|
||||
const parsed = JSON.parse(authStorage) as {
|
||||
state?: { token?: string };
|
||||
};
|
||||
|
||||
const token = parsed.state?.token;
|
||||
const {
|
||||
state: { token },
|
||||
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||
if (!token) return;
|
||||
|
||||
const response = await api.fetchAllLessons(token);
|
||||
|
||||
setLessonlLoading(false);
|
||||
setLessons(response.data);
|
||||
} catch (error) {
|
||||
setLessonlLoading(false);
|
||||
console.error("Error fetching lessons:", error);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLessonLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllLessons();
|
||||
}, [user]);
|
||||
|
||||
const renderGrid = (variant: "rw" | "math") => {
|
||||
if (lessonLoading) {
|
||||
return (
|
||||
<main className="min-h-screen space-y-6 max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8">
|
||||
<header className="space-y-2">
|
||||
<h1 className="font-satoshi-black text-2xl">Lessons</h1>
|
||||
<p className="font-satoshi-medium text-sm text-gray-500">
|
||||
Browse step-by-step lessons from expert Edbridge tutors and pick up
|
||||
tips to tackle similar questions with confidence.
|
||||
<div className="ls-skeleton-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<LessonSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!lessons.length) {
|
||||
return (
|
||||
<div className="ls-grid">
|
||||
<div className="ls-empty">
|
||||
<span className="ls-empty-emoji">📭</span>
|
||||
<p className="ls-empty-text">No lessons available yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="ls-grid">
|
||||
{lessons.map((lesson) => (
|
||||
<div
|
||||
key={lesson.id}
|
||||
className="ls-card"
|
||||
onClick={() => handleLessonClick(lesson.id)}
|
||||
>
|
||||
<img
|
||||
src={lesson.thumbnail_url}
|
||||
alt={lesson.title}
|
||||
className="ls-card-thumb"
|
||||
/>
|
||||
<div className="ls-card-body">
|
||||
<p className="ls-card-title">{lesson.title}</p>
|
||||
<p className={`ls-card-topic ${variant}`}>
|
||||
<span className={`ls-topic-dot ${variant}`} />
|
||||
{lesson.topic.name}
|
||||
</p>
|
||||
</header>
|
||||
<section>
|
||||
<Tabs defaultValue="rw">
|
||||
<TabsList className="bg-transparent space-x-4">
|
||||
<TabsTrigger
|
||||
value="rw"
|
||||
className="font-satoshi-bold px-2 tracking-wide text-md rounded-none border-b-2 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-purple-800 data-[state=active]:bg-transparent data-[state=active]:text-purple-800"
|
||||
>
|
||||
Reading & Writing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="math"
|
||||
className="font-satoshi-bold px-2 tracking-wide text-md rounded-none border-b-2 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800"
|
||||
>
|
||||
Math
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="rw" className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{lessonLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<LessonSkeleton key={i} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!lessonLoading && lessons.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
No lessons available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!lessonLoading && lessons.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{lessons.map((lesson) => (
|
||||
<Card
|
||||
key={lesson.id}
|
||||
onClick={() => handleLessonClick(lesson.id)}
|
||||
className="py-0 pb-5 rounded-4xl overflow-hidden"
|
||||
>
|
||||
<CardHeader className="w-full py-0 px-0">
|
||||
<img
|
||||
src={lesson.thumbnail_url}
|
||||
alt={lesson.title}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
<CardTitle>{lesson.title}</CardTitle>
|
||||
<CardDescription>{lesson.topic.name}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<LessonModal
|
||||
open={isModalOpen}
|
||||
lessonId={selectedLessonId}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setSelectedLessonId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="math" className="pt-4">
|
||||
{lessonLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<LessonSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!lessonLoading && lessons.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
No lessons available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!lessonLoading && lessons.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{lessons.map((lesson) => (
|
||||
<Card
|
||||
key={lesson.id}
|
||||
onClick={() => handleLessonClick(lesson.id)}
|
||||
className="py-0 pb-5 rounded-4xl overflow-hidden"
|
||||
>
|
||||
<CardHeader className="w-full py-0 px-0">
|
||||
<img
|
||||
src={lesson.thumbnail_url}
|
||||
alt={lesson.title}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
<CardTitle>{lesson.title}</CardTitle>
|
||||
<CardDescription>{lesson.topic.name}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<LessonModal
|
||||
open={isModalOpen}
|
||||
lessonId={selectedLessonId}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setSelectedLessonId(null);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ls-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="ls-blob ls-blob-1" />
|
||||
<div className="ls-blob ls-blob-2" />
|
||||
<div className="ls-blob ls-blob-3" />
|
||||
<div className="ls-blob ls-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="ls-dot"
|
||||
style={
|
||||
{
|
||||
width: d.size,
|
||||
height: d.size,
|
||||
background: d.color,
|
||||
top: d.top,
|
||||
left: d.left,
|
||||
right: d.right,
|
||||
animationDelay: d.delay,
|
||||
animationDuration: `${5 + i * 0.5}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="ls-inner">
|
||||
{/* Header */}
|
||||
<header className="ls-header">
|
||||
<h1 className="ls-title">📚 Lessons</h1>
|
||||
<p className="ls-sub">
|
||||
Step-by-step lessons from expert Edbridge tutors — pick up tips to
|
||||
tackle similar questions with confidence.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tabs + content */}
|
||||
<section className="ls-anim ls-anim-1">
|
||||
<div className="ls-tabs-list">
|
||||
<button
|
||||
className={`ls-tab-btn${activeTab === "rw" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("rw")}
|
||||
>
|
||||
<BookOpen size={15} /> Reading & Writing
|
||||
</button>
|
||||
<button
|
||||
className={`ls-tab-btn${activeTab === "math" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("math")}
|
||||
>
|
||||
<Calculator size={15} /> Math
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderGrid(activeTab)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<LessonModal
|
||||
open={isModalOpen}
|
||||
lessonId={selectedLessonId}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setSelectedLessonId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,134 +7,326 @@ import {
|
||||
Trophy,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
|
||||
const DOTS = [
|
||||
{ size: 10, color: "#f97316", top: "8%", left: "5%", delay: "0s" },
|
||||
{ size: 7, color: "#a855f7", top: "30%", left: "2%", delay: "1.2s" },
|
||||
{ size: 9, color: "#22c55e", top: "62%", left: "4%", delay: "0.6s" },
|
||||
{ size: 12, color: "#3b82f6", top: "12%", right: "4%", delay: "1.8s" },
|
||||
{ size: 7, color: "#f43f5e", top: "48%", right: "3%", delay: "0.9s" },
|
||||
{ size: 9, color: "#eab308", top: "78%", right: "6%", delay: "0.4s" },
|
||||
];
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
.pr-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Blobs ── */
|
||||
.pr-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
|
||||
.pr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:prWobble1 14s ease-in-out infinite; }
|
||||
.pr-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:prWobble2 16s ease-in-out infinite; }
|
||||
.pr-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:prWobble1 18s ease-in-out infinite reverse; }
|
||||
.pr-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:prWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes prWobble1 {
|
||||
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
|
||||
}
|
||||
@keyframes prWobble2 {
|
||||
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
|
||||
}
|
||||
|
||||
/* ── Floating dots ── */
|
||||
.pr-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:prFloat 7s ease-in-out infinite; }
|
||||
@keyframes prFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-12px) rotate(180deg);}
|
||||
}
|
||||
|
||||
/* ── Inner container ── */
|
||||
.pr-inner {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 580px; margin: 0 auto;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
display: flex; flex-direction: column; gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes prPopIn {
|
||||
from { opacity:0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity:1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.pr-anim { animation: prPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.pr-anim-1 { animation-delay: 0.05s; }
|
||||
.pr-anim-2 { animation-delay: 0.1s; }
|
||||
.pr-anim-3 { animation-delay: 0.15s; }
|
||||
.pr-anim-4 { animation-delay: 0.2s; }
|
||||
|
||||
/* ── Header ── */
|
||||
.pr-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
animation: prPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
.pr-logo-btn {
|
||||
width: 44px; height: 44px; border-radius: 14px;
|
||||
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 4px 0 #5b21b644;
|
||||
}
|
||||
.pr-xp-chip {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
background: white; border: 2.5px solid #e9d5ff;
|
||||
border-radius: 100px; padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem; font-weight: 800; color: #7c3aed;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
.pr-xp-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||
}
|
||||
|
||||
/* ── Hero banner ── */
|
||||
.pr-hero {
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
padding: 1.5rem;
|
||||
position: relative; overflow: hidden;
|
||||
box-shadow: 0 8px 0 #5b21b644, 0 12px 32px rgba(124,58,237,0.25);
|
||||
display: flex; flex-direction: column; gap: 0.75rem;
|
||||
}
|
||||
.pr-hero-icon-bg {
|
||||
position: absolute; right: -40px; top: -30px;
|
||||
opacity: 0.15; transform: rotate(-30deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pr-hero-eyebrow {
|
||||
font-size: 0.65rem; font-weight: 800; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: #e9d5ff;
|
||||
}
|
||||
.pr-hero-title {
|
||||
font-size: clamp(1.6rem, 5vw, 2rem); font-weight: 900;
|
||||
color: white; letter-spacing: -0.02em; line-height: 1.15;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.pr-hero-sub {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.85rem; font-weight: 600; color: #ddd6fe;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.pr-hero-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
background: white; border: none; border-radius: 100px;
|
||||
padding: 0.7rem 1.4rem; cursor: pointer;
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.88rem; font-weight: 800;
|
||||
color: #7c3aed;
|
||||
box-shadow: 0 4px 0 rgba(0,0,0,0.15);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
width: fit-content; position: relative; z-index: 1;
|
||||
}
|
||||
.pr-hero-btn:hover { transform:translateY(-2px); box-shadow:0 6px 0 rgba(0,0,0,0.15); }
|
||||
.pr-hero-btn:active { transform:translateY(2px); box-shadow:0 2px 0 rgba(0,0,0,0.15); }
|
||||
|
||||
/* ── Section title ── */
|
||||
.pr-section-title {
|
||||
font-size: 1.15rem; font-weight: 900; color: #1e1b4b;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* ── Practice mode grid ── */
|
||||
.pr-grid {
|
||||
display: grid; grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
@media(min-width: 480px){ .pr-grid { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
/* ── Mode card ── */
|
||||
.pr-mode-card {
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 22px;
|
||||
padding: 1.1rem 1.25rem;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.04);
|
||||
cursor: pointer; display: flex; flex-direction: column; gap: 0.85rem;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.pr-mode-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.08);
|
||||
}
|
||||
.pr-mode-card:active { transform: translateY(1px); box-shadow: 0 3px 8px rgba(0,0,0,0.06); }
|
||||
|
||||
.pr-mode-card.red { border-color: #fecaca; }
|
||||
.pr-mode-card.red:hover { border-color: #fca5a5; }
|
||||
.pr-mode-card.cyan { border-color: #a5f3fc; }
|
||||
.pr-mode-card.cyan:hover { border-color: #67e8f9; }
|
||||
.pr-mode-card.lime { border-color: #d9f99d; }
|
||||
.pr-mode-card.lime:hover { border-color: #bef264; }
|
||||
|
||||
.pr-mode-top {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
}
|
||||
.pr-mode-icon {
|
||||
width: 44px; height: 44px; border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pr-mode-icon.red { background: linear-gradient(135deg, #f87171, #ef4444); box-shadow: 0 4px 0 #b91c1c44; }
|
||||
.pr-mode-icon.cyan { background: linear-gradient(135deg, #22d3ee, #06b6d4); box-shadow: 0 4px 0 #0e7490aa; }
|
||||
.pr-mode-icon.lime { background: linear-gradient(135deg, #a3e635, #84cc16); box-shadow: 0 4px 0 #4d7c0f44; }
|
||||
|
||||
.pr-mode-badge {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pr-mode-badge.red { background: #fff5f5; }
|
||||
.pr-mode-badge.cyan { background: #ecfeff; }
|
||||
.pr-mode-badge.lime { background: #f7ffe4; }
|
||||
|
||||
.pr-mode-title {
|
||||
font-size: 1rem; font-weight: 900; color: #1e1b4b;
|
||||
}
|
||||
.pr-mode-desc {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.78rem; font-weight: 600; color: #9ca3af;
|
||||
}
|
||||
.pr-mode-arrow {
|
||||
font-size: 0.75rem; font-weight: 800; margin-top: auto;
|
||||
display: flex; align-items: center; gap: 0.25rem;
|
||||
transition: gap 0.2s ease;
|
||||
}
|
||||
.pr-mode-card:hover .pr-mode-arrow { gap: 0.5rem; }
|
||||
.pr-mode-arrow.red { color: #ef4444; }
|
||||
.pr-mode-arrow.cyan { color: #06b6d4; }
|
||||
.pr-mode-arrow.lime { color: #84cc16; }
|
||||
`;
|
||||
|
||||
const MODE_CARDS = [
|
||||
{
|
||||
color: "red",
|
||||
icon: <Target size={20} color="white" />,
|
||||
badge: <Loader2 size={22} color="#ef4444" />,
|
||||
title: "Targeted Practice",
|
||||
desc: "Focus on your weak spots and improve fast",
|
||||
route: "/student/practice/targeted-practice",
|
||||
arrow: "Practice →",
|
||||
},
|
||||
{
|
||||
color: "cyan",
|
||||
icon: <Zap size={20} color="white" />,
|
||||
badge: <Clock size={22} color="#06b6d4" />,
|
||||
title: "Drills",
|
||||
desc: "Train speed and accuracy under pressure",
|
||||
route: "/student/practice/drills",
|
||||
arrow: "Drill →",
|
||||
},
|
||||
{
|
||||
color: "lime",
|
||||
icon: <Trophy size={20} color="white" />,
|
||||
badge: <BookOpen size={22} color="#84cc16" />,
|
||||
title: "Hard Modules",
|
||||
desc: "Push yourself with the toughest questions",
|
||||
route: "/student/practice/hard-test-modules",
|
||||
arrow: "Challenge →",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const Practice = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const userXp = useExamConfigStore.getState().userXp;
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8 space-y-4">
|
||||
<header className="flex justify-between items-center ">
|
||||
<div className="w-fit bg-linear-to-br from-indigo-500 to-indigo-600 p-3 rounded-2xl">
|
||||
<div className="pr-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="pr-blob pr-blob-1" />
|
||||
<div className="pr-blob pr-blob-2" />
|
||||
<div className="pr-blob pr-blob-3" />
|
||||
<div className="pr-blob pr-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="pr-dot"
|
||||
style={
|
||||
{
|
||||
width: d.size,
|
||||
height: d.size,
|
||||
background: d.color,
|
||||
top: d.top,
|
||||
left: d.left,
|
||||
right: d.right,
|
||||
animationDelay: d.delay,
|
||||
animationDuration: `${5 + i * 0.5}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="pr-inner">
|
||||
{/* ── Header ── */}
|
||||
<header className="pr-header">
|
||||
<div className="pr-logo-btn">
|
||||
<BookOpen size={20} color="white" />
|
||||
</div>
|
||||
<div className="bg-indigo-100 rounded-full w-fit py-2 px-4 flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-linear-to-br from-indigo-400 to-indigo-500 rounded-full"></div>
|
||||
<span className="font-satoshi-bold text-md">{userXp}</span>
|
||||
<div className="pr-xp-chip">
|
||||
<div className="pr-xp-dot" />
|
||||
<span>⚡ {userXp} XP</span>
|
||||
</div>
|
||||
</header>
|
||||
<section>
|
||||
<Card
|
||||
className="relative bg-linear-to-br from-indigo-500 to-indigo-600 rounded-4xl
|
||||
flex-row"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<CardHeader className="w-[65%] md:w-full">
|
||||
<CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white">
|
||||
See where you stand
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="w-2/3 md:w-full">
|
||||
<p className="font-satoshi text-white">
|
||||
Test your knowledge with an adaptive practice test.
|
||||
{/* ── Hero banner ── */}
|
||||
<div className="pr-hero pr-anim pr-anim-1">
|
||||
<div className="pr-hero-icon-bg">
|
||||
<DraftingCompass size={240} color="white" />
|
||||
</div>
|
||||
<p className="pr-hero-eyebrow">🎯 Full Practice Test</p>
|
||||
<h2 className="pr-hero-title">See where you stand</h2>
|
||||
<p className="pr-hero-sub">
|
||||
Take a full adaptive test and benchmark your SAT readiness.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="bg-gray-50 drop-shadow-xl p-6 text-md font-satoshi text-black rounded-full">
|
||||
Take a practice test
|
||||
</Button>
|
||||
</CardFooter>
|
||||
<button className="pr-hero-btn">Take a practice test →</button>
|
||||
</div>
|
||||
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-10 -right-20">
|
||||
<DraftingCompass size={300} color="white" />
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
<section className="flex flex-col gap-6">
|
||||
<h1 className="font-satoshi-black text-2xl">Practice your way</h1>
|
||||
<div className="md:grid md:grid-cols-2 md:gap-6 space-y-6 md:space-y-0">
|
||||
<Card
|
||||
onClick={() => navigate("/student/practice/targeted-practice")}
|
||||
className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"
|
||||
|
||||
{/* ── Practice modes ── */}
|
||||
<section className="pr-anim pr-anim-2">
|
||||
<p className="pr-section-title" style={{ marginBottom: "0.85rem" }}>
|
||||
Practice your way
|
||||
</p>
|
||||
<div className="pr-grid">
|
||||
{MODE_CARDS.map((card) => (
|
||||
<div
|
||||
key={card.route}
|
||||
className={`pr-mode-card ${card.color}`}
|
||||
onClick={() => navigate(card.route)}
|
||||
>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl">
|
||||
<Target size={20} color="white" />
|
||||
<div className="pr-mode-top">
|
||||
<div className={`pr-mode-icon ${card.color}`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="font-satoshi">
|
||||
Targeted Practice
|
||||
</CardTitle>
|
||||
<CardDescription className="font-satoshi">
|
||||
Focus on what matters
|
||||
</CardDescription>
|
||||
<div className={`pr-mode-badge ${card.color}`}>
|
||||
{card.badge}
|
||||
</div>
|
||||
<CardAction>
|
||||
<div className="w-fit bg-red-100 p-2 rounded-full">
|
||||
<Loader2 size={30} color="#fa6969" />
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card
|
||||
onClick={() => navigate("/student/practice/drills")}
|
||||
className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"
|
||||
>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="w-fit bg-linear-to-br from-cyan-400 to-cyan-500 p-3 rounded-2xl">
|
||||
<Zap size={20} color="white" />
|
||||
<div>
|
||||
<p className="pr-mode-title">{card.title}</p>
|
||||
<p className="pr-mode-desc">{card.desc}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="font-satoshi">Drills</CardTitle>
|
||||
<CardDescription className="font-satoshi">
|
||||
Train speed and accuracy
|
||||
</CardDescription>
|
||||
<p className={`pr-mode-arrow ${card.color}`}>{card.arrow}</p>
|
||||
</div>
|
||||
<CardAction>
|
||||
<div className="w-fit bg-cyan-100 p-3 rounded-full">
|
||||
<Clock size={26} color="oklch(71.5% 0.143 215.221)" />
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card
|
||||
onClick={() => navigate("/student/practice/hard-test-modules")}
|
||||
className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"
|
||||
>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="w-fit bg-linear-to-br from-lime-400 to-lime-500 p-3 rounded-2xl">
|
||||
<Trophy size={20} color="white" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="font-satoshi">
|
||||
Hard Test Modules
|
||||
</CardTitle>
|
||||
<CardDescription className="font-satoshi">
|
||||
Focus on what matters
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CardAction>
|
||||
<div className="w-fit bg-lime-100 p-3 rounded-full">
|
||||
<BookOpen size={26} color="oklch(76.8% 0.233 130.85)" />
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,185 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, LogOut, User } from "lucide-react";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../components/ui/avatar";
|
||||
|
||||
const DOTS = [
|
||||
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
|
||||
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
|
||||
{ size: 9, color: "#22c55e", top: "62%", left: "3%", delay: "0.6s" },
|
||||
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
|
||||
{ size: 7, color: "#f43f5e", top: "48%", right: "2%", delay: "0.9s" },
|
||||
{ size: 9, color: "#eab308", top: "76%", right: "5%", delay: "0.4s" },
|
||||
];
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
.pf-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.pf-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
|
||||
.pf-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:pfWobble1 14s ease-in-out infinite; }
|
||||
.pf-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:pfWobble2 16s ease-in-out infinite; }
|
||||
.pf-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:pfWobble1 18s ease-in-out infinite reverse; }
|
||||
.pf-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:pfWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes pfWobble1 {
|
||||
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
|
||||
}
|
||||
@keyframes pfWobble2 {
|
||||
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
|
||||
}
|
||||
|
||||
.pf-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:pfFloat 7s ease-in-out infinite; }
|
||||
@keyframes pfFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-12px) rotate(180deg);}
|
||||
}
|
||||
|
||||
.pf-inner {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 520px; margin: 0 auto;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
display: flex; flex-direction: column; gap: 1.5rem;
|
||||
}
|
||||
|
||||
@keyframes pfPopIn {
|
||||
from { opacity:0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity:1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.pf-anim { animation: pfPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.pf-anim-1 { animation-delay: 0.05s; }
|
||||
.pf-anim-2 { animation-delay: 0.1s; }
|
||||
.pf-anim-3 { animation-delay: 0.15s; }
|
||||
.pf-anim-4 { animation-delay: 0.2s; }
|
||||
.pf-anim-5 { animation-delay: 0.25s; }
|
||||
|
||||
/* Page title */
|
||||
.pf-page-title {
|
||||
font-size: 1.8rem; font-weight: 900; color: #1e1b4b;
|
||||
letter-spacing: -0.02em; text-align: center;
|
||||
animation: pfPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
|
||||
/* Avatar hero card */
|
||||
.pf-hero {
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 24px;
|
||||
padding: 1.5rem; box-shadow: 0 6px 20px rgba(0,0,0,0.05);
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.pf-avatar-wrap {
|
||||
width: 64px; height: 64px; flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 4px 0 #5b21b655;
|
||||
}
|
||||
.pf-user-name {
|
||||
font-size: 1.15rem; font-weight: 900; color: #1e1b4b; line-height: 1.2;
|
||||
}
|
||||
.pf-user-email {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.8rem; font-weight: 600; color: #9ca3af; margin-top: 0.2rem;
|
||||
}
|
||||
.pf-user-badge {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
background: #f3e8ff; border: 2px solid #e9d5ff; border-radius: 100px;
|
||||
padding: 0.2rem 0.6rem; margin-top: 0.35rem;
|
||||
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; color: #9333ea;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.pf-section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.pf-section-label {
|
||||
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: #9ca3af; padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Settings group card */
|
||||
.pf-group {
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 20px;
|
||||
overflow: hidden; box-shadow: 0 4px 14px rgba(0,0,0,0.04);
|
||||
}
|
||||
.pf-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.9rem 1.1rem; gap: 0.75rem; cursor: pointer;
|
||||
background: white; border: none; width: 100%; text-align: left;
|
||||
transition: background 0.15s ease;
|
||||
border-bottom: 2px solid #f9fafb;
|
||||
}
|
||||
.pf-row:last-child { border-bottom: none; }
|
||||
.pf-row:hover { background: #fafaf9; }
|
||||
.pf-row:active { background: #f5f3ef; }
|
||||
|
||||
.pf-row-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.pf-row-icon {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pf-row-label {
|
||||
font-size: 0.9rem; font-weight: 800; color: #1e1b4b;
|
||||
}
|
||||
.pf-row-sub {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.72rem; font-weight: 600; color: #9ca3af;
|
||||
}
|
||||
.pf-chevron { color: #d1d5db; flex-shrink: 0; }
|
||||
|
||||
/* Sign out button */
|
||||
.pf-signout-btn {
|
||||
width: 100%;
|
||||
background: #f97316; color: white; border: none;
|
||||
border-radius: 100px; padding: 1rem;
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.95rem; font-weight: 800;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
box-shadow: 0 6px 0 #c2560e, 0 8px 20px rgba(249,115,22,0.25);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.pf-signout-btn:hover { transform:translateY(-2px); box-shadow:0 8px 0 #c2560e,0 12px 24px rgba(249,115,22,0.3); }
|
||||
.pf-signout-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #c2560e; }
|
||||
`;
|
||||
|
||||
type RowDef = {
|
||||
icon: string;
|
||||
iconBg: string;
|
||||
label: string;
|
||||
sub?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const SettingsGroup = ({ rows }: { rows: RowDef[] }) => (
|
||||
<div className="pf-group">
|
||||
{rows.map((row, i) => (
|
||||
<button key={i} className="pf-row" onClick={row.onClick}>
|
||||
<div className="pf-row-left">
|
||||
<div className="pf-row-icon" style={{ background: row.iconBg }}>
|
||||
<span style={{ fontSize: "1rem" }}>{row.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="pf-row-label">{row.label}</p>
|
||||
{row.sub && <p className="pf-row-sub">{row.sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={17} className="pf-chevron" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Profile = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
@ -11,62 +190,142 @@ export const Profile = () => {
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const ACCOUNT_ROWS: RowDef[] = [
|
||||
{
|
||||
icon: "👤",
|
||||
iconBg: "#f3e8ff",
|
||||
label: "Account details",
|
||||
sub: "Name, email, password",
|
||||
},
|
||||
{
|
||||
icon: "🎟️",
|
||||
iconBg: "#fff7ed",
|
||||
label: "Redeem a code",
|
||||
sub: "Enter your access code",
|
||||
},
|
||||
{
|
||||
icon: "💳",
|
||||
iconBg: "#f0fdf4",
|
||||
label: "Manage subscription",
|
||||
sub: "Plans & billing",
|
||||
},
|
||||
{
|
||||
icon: "⚙️",
|
||||
iconBg: "#f8fafc",
|
||||
label: "Preferences",
|
||||
sub: "Notifications & display",
|
||||
},
|
||||
];
|
||||
|
||||
const LEGAL_ROWS: RowDef[] = [
|
||||
{ icon: "📄", iconBg: "#eff6ff", label: "Terms of Use" },
|
||||
{ icon: "🔒", iconBg: "#fff1f2", label: "Privacy Policy" },
|
||||
];
|
||||
|
||||
const SUPPORT_ROWS: RowDef[] = [
|
||||
{
|
||||
icon: "💬",
|
||||
iconBg: "#f0fdf4",
|
||||
label: "Contact Us",
|
||||
sub: "We usually respond within 24h",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen space-y-6 mx-auto p-8">
|
||||
<h1 className="text-lg font-satoshi-bold text-center">Profile</h1>
|
||||
<section>
|
||||
<h3 className="text-2xl font-satoshi-bold">{user?.name}</h3>
|
||||
<p className="text-lg font-satoshi">{user?.email}</p>
|
||||
</section>
|
||||
<section className="space-y-4">
|
||||
<h5 className="text-[12px] font-satoshi-bold text-gray-400">ACCOUNT</h5>
|
||||
<div className="border rounded-4xl">
|
||||
<button className="w-full rounded-t-4xl border-b bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
||||
<span className="font-satoshi text-black">Account details</span>
|
||||
<ChevronRight color="gray" size={20} />
|
||||
</button>
|
||||
<button className="w-full border-b bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
||||
<span className="font-satoshi text-black">Redeem a code</span>
|
||||
<ChevronRight color="gray" size={20} />
|
||||
</button>
|
||||
<button className="w-full border-b bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
||||
<span className="font-satoshi text-black">Manage subscription</span>
|
||||
<ChevronRight color="gray" size={20} />
|
||||
</button>
|
||||
<button className="w-full rounded-b-4xl bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
||||
<span className="font-satoshi text-black">Preferences</span>
|
||||
<ChevronRight color="gray" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4">
|
||||
<h5 className="text-[12px] font-satoshi-bold text-gray-400">LEGAL</h5>
|
||||
<div className="border rounded-4xl">
|
||||
<button className="w-full border-b rounded-t-4xl bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
||||
<span className="font-satoshi text-black">Terms of Use</span>
|
||||
<ChevronRight color="gray" size={20} />
|
||||
</button>
|
||||
<button className="w-full border-b rounded-b-4xl bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
||||
<span className="font-satoshi text-black">Privacy Policy</span>
|
||||
<ChevronRight color="gray" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4">
|
||||
<h5 className="text-[12px] font-satoshi-bold text-gray-400">SUPPORT</h5>
|
||||
<div className="border rounded-4xl">
|
||||
<button className="w-full rounded-4xl bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
||||
<span className="font-satoshi text-black">Contact Us</span>
|
||||
<ChevronRight color="gray" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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"
|
||||
<div className="pf-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="pf-blob pf-blob-1" />
|
||||
<div className="pf-blob pf-blob-2" />
|
||||
<div className="pf-blob pf-blob-3" />
|
||||
<div className="pf-blob pf-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="pf-dot"
|
||||
style={
|
||||
{
|
||||
width: d.size,
|
||||
height: d.size,
|
||||
background: d.color,
|
||||
top: d.top,
|
||||
left: d.left,
|
||||
right: d.right,
|
||||
animationDelay: d.delay,
|
||||
animationDuration: `${5 + i * 0.5}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="pf-inner">
|
||||
<h1 className="pf-page-title">Profile</h1>
|
||||
|
||||
{/* Hero card */}
|
||||
<div className="pf-hero pf-anim pf-anim-1">
|
||||
<Avatar style={{ width: 64, height: 64, flexShrink: 0 }}>
|
||||
<AvatarImage src={user?.avatar_url} />
|
||||
<AvatarFallback
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg,#a855f7,#7c3aed)",
|
||||
color: "white",
|
||||
fontWeight: 900,
|
||||
fontSize: "1.5rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 4px 0 #5b21b655",
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
{user?.name?.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="pf-user-name">{user?.name}</p>
|
||||
<p className="pf-user-email">{user?.email}</p>
|
||||
<div className="pf-user-badge">
|
||||
<User size={9} />
|
||||
{user?.role === "STUDENT"
|
||||
? "Student"
|
||||
: user?.role === "ADMIN"
|
||||
? "Admin"
|
||||
: "Teacher"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account */}
|
||||
<section className="pf-section pf-anim pf-anim-2">
|
||||
<p className="pf-section-label">Account</p>
|
||||
<SettingsGroup rows={ACCOUNT_ROWS} />
|
||||
</section>
|
||||
|
||||
{/* Legal */}
|
||||
<section className="pf-section pf-anim pf-anim-3">
|
||||
<p className="pf-section-label">Legal</p>
|
||||
<SettingsGroup rows={LEGAL_ROWS} />
|
||||
</section>
|
||||
|
||||
{/* Support */}
|
||||
<section className="pf-section pf-anim pf-anim-4">
|
||||
<p className="pf-section-label">Support</p>
|
||||
<SettingsGroup rows={SUPPORT_ROWS} />
|
||||
</section>
|
||||
|
||||
{/* Sign out */}
|
||||
<button
|
||||
className="pf-signout-btn pf-anim pf-anim-5"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut size={18} /> Sign Out
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,23 +2,18 @@ import { useAuthStore } from "../../stores/authStore";
|
||||
import firstTrophy from "../../assets/icons/first_trophy.png";
|
||||
import secondTrophy from "../../assets/icons/second_trophy.png";
|
||||
import thirdTrophy from "../../assets/icons/third_trophy.png";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
// import {
|
||||
// Card,
|
||||
// CardHeader,
|
||||
// CardContent,
|
||||
// CardTitle,
|
||||
// CardDescription,
|
||||
// } from "../../components/ui/card";
|
||||
import { formatTimeFilter, getRandomColor } from "../../lib/utils";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../components/ui/tabs";
|
||||
|
||||
import { Button } from "../../components/ui//button";
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../components/ui/avatar";
|
||||
import { Flame, LucideBadgeQuestionMark, Zap, ChevronDown } from "lucide-react";
|
||||
import type { Leaderboard } from "../../types/leaderboard";
|
||||
import { api } from "../../utils/api";
|
||||
import { LeaderboardRowSkeleton } from "../../components/LeaderboardSkeleton";
|
||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -26,118 +21,456 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../components/ui/dropdown-menu";
|
||||
import { formatTimeFilter, getRandomColor } from "../../lib/utils";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../components/ui/avatar";
|
||||
import { Flame, LucideBadgeQuestionMark, Zap } from "lucide-react";
|
||||
import type { Leaderboard } from "../../types/leaderboard";
|
||||
import { api } from "../../utils/api";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { LeaderboardRowSkeleton } from "../../components/LeaderboardSkeleton";
|
||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||
|
||||
const DOTS = [
|
||||
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
|
||||
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
|
||||
{ size: 9, color: "#22c55e", top: "58%", left: "3%", delay: "0.6s" },
|
||||
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
|
||||
{ size: 7, color: "#f43f5e", top: "42%", right: "2%", delay: "0.9s" },
|
||||
{ size: 9, color: "#eab308", top: "72%", right: "5%", delay: "0.4s" },
|
||||
];
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
.rw-screen {
|
||||
height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.rw-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
|
||||
.rw-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:rwWobble1 14s ease-in-out infinite; }
|
||||
.rw-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:rwWobble2 16s ease-in-out infinite; }
|
||||
.rw-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:rwWobble1 18s ease-in-out infinite reverse; }
|
||||
.rw-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:rwWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes rwWobble1 {
|
||||
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
|
||||
}
|
||||
@keyframes rwWobble2 {
|
||||
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
|
||||
}
|
||||
|
||||
.rw-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:rwFloat 7s ease-in-out infinite; }
|
||||
@keyframes rwFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-12px) rotate(180deg);}
|
||||
}
|
||||
|
||||
/* Sticky top wrapper */
|
||||
.rw-sticky-top {
|
||||
position: relative; z-index: 2;
|
||||
background: #fffbf4;
|
||||
flex-shrink: 0;
|
||||
padding: 2rem 1.25rem 0;
|
||||
}
|
||||
.rw-sticky-top-inner {
|
||||
max-width: 580px; margin: 0 auto;
|
||||
display: flex; flex-direction: column; gap: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f3f4f6;
|
||||
}
|
||||
|
||||
/* Scrollable list area */
|
||||
.rw-scroll-area {
|
||||
position: relative; z-index: 1;
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 1rem 1.25rem 10rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.rw-scroll-inner {
|
||||
max-width: 580px; margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes rwPopIn {
|
||||
from { opacity:0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity:1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.rw-anim { animation: rwPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.rw-anim-1 { animation-delay: 0.05s; }
|
||||
.rw-anim-2 { animation-delay: 0.1s; }
|
||||
|
||||
/* Header */
|
||||
.rw-header { display:flex;flex-direction:column;align-items:center;gap:0.4rem;text-align:center; }
|
||||
.rw-title { font-size:1.9rem;font-weight:900;color:#1e1b4b;letter-spacing:-0.02em; }
|
||||
.rw-rank-text { font-family:'Nunito Sans',sans-serif;font-size:0.9rem;font-weight:600;color:#9ca3af; }
|
||||
.rw-rank-text span { color:#a855f7;font-weight:800; }
|
||||
|
||||
/* Controls row */
|
||||
.rw-controls {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tab pills */
|
||||
.rw-tabs { display:flex;gap:0.4rem; }
|
||||
.rw-tab-btn {
|
||||
padding: 0.45rem 0.9rem; border-radius: 100px; border: none; cursor: pointer;
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.78rem; font-weight: 800;
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
transition: all 0.2s ease;
|
||||
background: white; border: 2.5px solid #f3f4f6; color: #9ca3af;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.rw-tab-btn.active { background:#1e1b4b; border-color:#1e1b4b; color:white; box-shadow:0 4px 0 #1e1b4b55; }
|
||||
|
||||
/* Time filter button */
|
||||
.rw-filter-btn {
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.45rem 0.9rem; border-radius: 100px; cursor: pointer;
|
||||
font-family: 'Nunito', sans-serif; font-size: 0.78rem; font-weight: 800;
|
||||
background: white; border: 2.5px solid #f3f4f6; color: #6b7280;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.rw-filter-btn:hover { border-color: #c4b5fd; color: #7c3aed; }
|
||||
|
||||
/* Leaderboard list */
|
||||
.rw-list { display:flex;flex-direction:column;gap:0.6rem; }
|
||||
|
||||
/* Each row */
|
||||
.rw-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 18px;
|
||||
padding: 0.7rem 1rem;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.rw-row:hover { transform:translateY(-1px); box-shadow:0 6px 16px rgba(0,0,0,0.07); }
|
||||
.rw-row.top-1 { border-color:#fde68a; background:linear-gradient(135deg,#fffbeb,white); }
|
||||
.rw-row.top-2 { border-color:#e2e8f0; background:linear-gradient(135deg,#f8fafc,white); }
|
||||
.rw-row.top-3 { border-color:#fecba8; background:linear-gradient(135deg,#fff7ed,white); }
|
||||
|
||||
.rw-row-left { display:flex;align-items:center;gap:0.75rem; }
|
||||
.rw-rank-cell { width:36px;display:flex;align-items:center;justify-content:center; }
|
||||
.rw-rank-num { font-size:0.9rem;font-weight:900;color:#9ca3af; }
|
||||
.rw-user-name { font-size:0.88rem;font-weight:800;color:#1e1b4b; }
|
||||
.rw-row-right { display:flex;align-items:center;gap:0.35rem; }
|
||||
.rw-score { font-size:0.9rem;font-weight:900;color:#1e1b4b; }
|
||||
|
||||
/* XP chip color variants */
|
||||
.rw-xp-chip { color:#84cc16; }
|
||||
.rw-q-chip { color:#0891b2; }
|
||||
.rw-fire-chip { color:#ef4444; }
|
||||
|
||||
/* Skeleton */
|
||||
.rw-skeleton { display:flex;flex-direction:column;gap:0.6rem; }
|
||||
|
||||
/* ── Floating island you-pill ── */
|
||||
.rw-island-wrap {
|
||||
position: fixed;
|
||||
bottom: calc(1.25rem + 80px + env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
max-width: 480px
|
||||
}
|
||||
|
||||
/* Expanded info card — slides up */
|
||||
.rw-island-card {
|
||||
background: rgba(255,251,244,0.92);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1.5px solid rgba(255,255,255,0.8);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.9);
|
||||
padding: 1rem 1.25rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
min-width: 350px;
|
||||
|
||||
/* Hidden by default */
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.95);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.3s cubic-bezier(0.34,1.56,0.64,1),
|
||||
transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
.rw-island-card.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.rw-island-stat {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
|
||||
padding: 0.5rem 0.4rem;
|
||||
background: white; border: 2px solid #f3f4f6; border-radius: 14px;
|
||||
}
|
||||
.rw-island-stat-val {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 1rem; font-weight: 900; color: #1e1b4b; line-height: 1;
|
||||
}
|
||||
.rw-island-stat-label {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; color: #9ca3af;
|
||||
}
|
||||
.rw-island-stat.xp { border-color: #d9f99d; }
|
||||
.rw-island-stat.xp .rw-island-stat-val { color: #16a34a; }
|
||||
.rw-island-stat.rank { border-color: #e9d5ff; }
|
||||
.rw-island-stat.rank .rw-island-stat-val { color: #9333ea; }
|
||||
.rw-island-stat.lvl { border-color: #bfdbfe; }
|
||||
.rw-island-stat.lvl .rw-island-stat-val { color: #2563eb; }
|
||||
|
||||
/* The pill button itself */
|
||||
.rw-island-pill {
|
||||
|
||||
display: flex; align-items: center; gap: 0.65rem;
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
border: 1.5px solid rgba(255,255,255,0.25);
|
||||
border-radius: 100px;
|
||||
padding: 0.45rem 1rem 0.45rem 0.45rem;
|
||||
box-shadow:
|
||||
0 6px 20px rgba(124,58,237,0.35),
|
||||
0 2px 6px rgba(124,58,237,0.2),
|
||||
inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.2s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s ease;
|
||||
}
|
||||
.rw-island-pill:active { transform: scale(0.93); }
|
||||
.rw-island-pill.open {
|
||||
box-shadow:
|
||||
0 10px 28px rgba(124,58,237,0.4),
|
||||
0 4px 10px rgba(124,58,237,0.25),
|
||||
inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.rw-island-avatar {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.25);
|
||||
border: 2px solid rgba(255,255,255,0.4);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.9rem; font-weight: 900; color: white;
|
||||
overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.rw-island-avatar img { width:100%;height:100%;object-fit:cover; }
|
||||
|
||||
.rw-island-info { display:flex;flex-direction:column;gap:1px; }
|
||||
.rw-island-you {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.58rem; font-weight: 800; letter-spacing:0.12em;
|
||||
text-transform: uppercase; color: rgba(255,255,255,0.6);
|
||||
line-height: 1;
|
||||
}
|
||||
.rw-island-name {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.88rem; font-weight: 900; color: white;
|
||||
line-height: 1.1; white-space: nowrap;
|
||||
}
|
||||
|
||||
.rw-island-metric {
|
||||
display: flex; align-items: center; gap: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1.5px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
.rw-island-metric-val {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.95rem; font-weight: 900; color: white;
|
||||
}
|
||||
|
||||
.rw-island-chevron {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
.rw-island-pill.open .rw-island-chevron { transform: rotate(180deg); }
|
||||
|
||||
/* Shimmer skeleton line */
|
||||
.rw-skel-row {
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
background:white;border:2.5px solid #f3f4f6;border-radius:18px;
|
||||
padding:0.7rem 1rem;
|
||||
}
|
||||
.rw-skel-left { display:flex;align-items:center;gap:0.75rem; }
|
||||
.rw-skel-circle { border-radius:50%;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; }
|
||||
.rw-skel-line { border-radius:6px;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; }
|
||||
@keyframes rwShimmer {
|
||||
0%,100%{opacity:1;} 50%{opacity:0.4;}
|
||||
}
|
||||
`;
|
||||
|
||||
const TABS = [
|
||||
{ id: "xp", label: "XP", icon: <Zap size={13} /> },
|
||||
{
|
||||
id: "questions",
|
||||
label: "Questions",
|
||||
icon: <LucideBadgeQuestionMark size={13} />,
|
||||
},
|
||||
{ id: "streak", label: "Streak", icon: <Flame size={13} /> },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
const trophies = [firstTrophy, secondTrophy, thirdTrophy];
|
||||
|
||||
const SkeletonRows = () => (
|
||||
<div className="rw-skeleton">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="rw-skel-row">
|
||||
<div className="rw-skel-left">
|
||||
<div className="rw-skel-circle" style={{ width: 36, height: 36 }} />
|
||||
<div className="rw-skel-circle" style={{ width: 40, height: 40 }} />
|
||||
<div
|
||||
className="rw-skel-line"
|
||||
style={{ width: 100 + i * 10, height: 12 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="rw-skel-line" style={{ width: 40, height: 12 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Rewards = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const [time, setTime] = useState("bottom");
|
||||
const [activeTab, setActiveTab] = useState("xp");
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>("xp");
|
||||
const [leaderboard, setLeaderboard] = useState<Leaderboard>();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [islandOpen, setIslandOpen] = useState(false);
|
||||
const { setUserXp } = useExamConfigStore();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLeaderboard = async () => {
|
||||
const fetch = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const authStorage = localStorage.getItem("auth-storage");
|
||||
if (!authStorage) return;
|
||||
|
||||
const parsed = JSON.parse(authStorage) as {
|
||||
state?: { token?: string };
|
||||
};
|
||||
|
||||
const token = parsed.state?.token;
|
||||
const {
|
||||
state: { token },
|
||||
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||
if (!token) return;
|
||||
|
||||
const response = await api.fetchLeaderboard(token);
|
||||
|
||||
setLeaderboard(response);
|
||||
|
||||
setUserXp(response.user_rank.total_xp);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
console.error("Error fetching leaderboard: " + error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLeaderboard();
|
||||
fetch();
|
||||
}, [user]);
|
||||
|
||||
const trophies = [firstTrophy, secondTrophy, thirdTrophy];
|
||||
const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) <= 3;
|
||||
|
||||
const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) < 3;
|
||||
const metricValue = (u: (typeof leaderboard.top_users)[0]) =>
|
||||
activeTab === "xp" ? u.total_xp : activeTab === "questions" ? "—" : "—";
|
||||
|
||||
const metricIcon = () =>
|
||||
activeTab === "xp" ? (
|
||||
<Zap size={17} className="rw-xp-chip" />
|
||||
) : activeTab === "questions" ? (
|
||||
<LucideBadgeQuestionMark size={17} className="rw-q-chip" />
|
||||
) : (
|
||||
<Flame size={17} className="rw-fire-chip" />
|
||||
);
|
||||
|
||||
const userMetric =
|
||||
activeTab === "xp"
|
||||
? leaderboard?.user_rank.total_xp
|
||||
: activeTab === "questions"
|
||||
? "23"
|
||||
: "5";
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="font-satoshi-black text-3xl">Leaderboards</h1>
|
||||
<div className="rw-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="rw-blob rw-blob-1" />
|
||||
<div className="rw-blob rw-blob-2" />
|
||||
<div className="rw-blob rw-blob-3" />
|
||||
<div className="rw-blob rw-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rw-dot"
|
||||
style={
|
||||
{
|
||||
width: d.size,
|
||||
height: d.size,
|
||||
background: d.color,
|
||||
top: d.top,
|
||||
left: d.left,
|
||||
right: d.right,
|
||||
animationDelay: d.delay,
|
||||
animationDuration: `${5 + i * 0.5}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Sticky top: header + controls */}
|
||||
<div className="rw-sticky-top">
|
||||
<div className="rw-sticky-top-inner">
|
||||
{/* Header */}
|
||||
<header className="rw-header rw-anim">
|
||||
<h1 className="rw-title">🏆 Leaderboard</h1>
|
||||
{loading ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-60 bg-gray-200 rounded" />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 16,
|
||||
width: 180,
|
||||
background: "#f3f4f6",
|
||||
borderRadius: 8,
|
||||
animation: "rwShimmer 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="font-satoshi-medium text-md text-gray-500">
|
||||
Don't stop now! You're{" "}
|
||||
<span className="text-indigo-400">
|
||||
#{leaderboard?.user_rank.rank}
|
||||
</span>{" "}
|
||||
in XP.
|
||||
<p className="rw-rank-text">
|
||||
You're <span>#{leaderboard?.user_rank.rank}</span> — keep
|
||||
grinding!
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<section className="w-full px-7">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
defaultValue="xp"
|
||||
className="space-y-6 h-[calc(100vh-250px)] flex flex-col"
|
||||
|
||||
{/* Controls */}
|
||||
<div className="rw-controls rw-anim rw-anim-1">
|
||||
<div className="rw-tabs">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`rw-tab-btn${activeTab === tab.id ? " active" : ""}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<TabsList className="bg-transparent p-0 w-full justify-between shrink-0">
|
||||
<TabsTrigger
|
||||
value="xp"
|
||||
className="font-satoshi-bold px-4 tracking-wide text-md rounded-none border-b-2 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
XP
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="questions"
|
||||
className="font-satoshi-bold px-4 tracking-wide text-md rounded-none border-b-2 data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
Questions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="streak"
|
||||
className="font-satoshi-bold px-4 tracking-wide text-md rounded-none border-b-2 data-[state=active]:border-b-indigo-800 data-[state=active]:text-indigo-800"
|
||||
>
|
||||
Streak
|
||||
</TabsTrigger>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-full px-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-satoshi-bold text-sm text-black"
|
||||
>
|
||||
{formatTimeFilter(time)}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="rw-filter-btn">
|
||||
{formatTimeFilter(time)} <ChevronDown size={13} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="font-satoshi">
|
||||
<DropdownMenuContent
|
||||
style={{ fontFamily: "'Nunito',sans-serif", fontWeight: 700 }}
|
||||
>
|
||||
<DropdownMenuRadioGroup value={time} onValueChange={setTime}>
|
||||
<DropdownMenuRadioItem value="today">
|
||||
Today
|
||||
@ -154,219 +487,141 @@ export const Rewards = () => {
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="xp"
|
||||
className="flex-1 overflow-y-auto space-y-6 pb-8"
|
||||
</div>
|
||||
</div>
|
||||
{/* end rw-sticky-top-inner */}
|
||||
</div>
|
||||
{/* end rw-sticky-top */}
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div className="rw-scroll-area">
|
||||
<div className="rw-scroll-inner">
|
||||
<div
|
||||
className="rw-list rw-anim rw-anim-2"
|
||||
style={{ paddingTop: "1rem" }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<LeaderboardRowSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
<SkeletonRows />
|
||||
) : (
|
||||
leaderboard?.top_users.map((user, index) => {
|
||||
const isTopThree = index < 3;
|
||||
|
||||
leaderboard?.top_users.map((u, index) => {
|
||||
const top = index < 3;
|
||||
const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`;
|
||||
return (
|
||||
<div
|
||||
key={user.user_id}
|
||||
className="flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isTopThree ? (
|
||||
<div key={u.user_id} className={rowClass}>
|
||||
<div className="rw-row-left">
|
||||
<div className="rw-rank-cell">
|
||||
{top ? (
|
||||
<img
|
||||
src={trophies[index]}
|
||||
alt={`trophy_${index + 1}`}
|
||||
className="w-12 h-12"
|
||||
alt={`#${index + 1}`}
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-12 text-center font-satoshi-bold text-gray-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="rw-rank-num">{index + 1}</span>
|
||||
)}
|
||||
|
||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
||||
<AvatarImage src={user.avatar_url} />
|
||||
<AvatarFallback className="text-white font-satoshi-bold">
|
||||
{user.name.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<Avatar
|
||||
className={`${getRandomColor()}`}
|
||||
style={{ width: 38, height: 38 }}
|
||||
>
|
||||
<AvatarImage src={u.avatar_url} />
|
||||
<AvatarFallback
|
||||
style={{
|
||||
color: "black",
|
||||
fontWeight: 900,
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{u.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<p className="font-satoshi-medium text-gray-600">
|
||||
{user.name}
|
||||
</p>
|
||||
<span className="rw-user-name">{u.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-satoshi-medium">{user.total_xp}</p>
|
||||
<Zap size={20} className="text-lime-500 fill-lime-200" />
|
||||
<div className="rw-row-right">
|
||||
<span className="rw-score">{metricValue(u)}</span>
|
||||
{metricIcon()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="questions"
|
||||
className="flex-1 overflow-y-auto space-y-6"
|
||||
>
|
||||
{/* {leaderboard.map((user, index) => {
|
||||
const isTopThree = index < 3;
|
||||
</div>
|
||||
</div>
|
||||
{/* end rw-scroll-inner */}
|
||||
</div>
|
||||
{/* end rw-scroll-area */}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isTopThree ? (
|
||||
<img
|
||||
src={trophies[index]}
|
||||
alt={`trophy_${index + 1}`}
|
||||
className="w-12 h-12"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-12 text-center font-satoshi-bold text-gray-400">
|
||||
{index + 1}
|
||||
{/* ── Floating island pill ── */}
|
||||
<div className="rw-island-wrap">
|
||||
{/* Expanded info card */}
|
||||
<div className={`rw-island-card${islandOpen ? " open" : ""}`}>
|
||||
<div className="rw-island-stat rank">
|
||||
<span className="rw-island-stat-val">
|
||||
#{leaderboard?.user_rank?.rank ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
||||
<AvatarFallback className="text-white font-satoshi-bold">
|
||||
{user.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<p className="font-satoshi-medium text-gray-600">
|
||||
{user.name}
|
||||
</p>
|
||||
<span className="rw-island-stat-label">Rank</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-satoshi-medium">{user.xp}</p>
|
||||
<Zap size={20} color="darkgreen" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})} */}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="streak"
|
||||
className="flex-1 overflow-y-auto space-y-6"
|
||||
>
|
||||
{/* {leaderboard.map((user, index) => {
|
||||
const isTopThree = index < 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isTopThree ? (
|
||||
<img
|
||||
src={trophies[index]}
|
||||
alt={`trophy_${index + 1}`}
|
||||
className="w-12 h-12"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-12 text-center font-satoshi-bold text-gray-400">
|
||||
{index + 1}
|
||||
<div className="rw-island-stat xp">
|
||||
<span className="rw-island-stat-val">
|
||||
{leaderboard?.user_rank?.total_xp ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
||||
<AvatarFallback className="text-white font-satoshi-bold">
|
||||
{user.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<p className="font-satoshi-medium text-gray-600">
|
||||
{user.name}
|
||||
</p>
|
||||
<span className="rw-island-stat-label">Total XP</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-satoshi-medium">{user.xp}</p>
|
||||
<Zap size={20} color="darkgreen" />
|
||||
<div className="rw-island-stat lvl">
|
||||
<span className="rw-island-stat-val">
|
||||
{leaderboard?.user_rank?.current_level ?? "—"}
|
||||
</span>
|
||||
<span className="rw-island-stat-label">Level</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})} */}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
<Card className="absolute mx-auto w-full left-0 md:-bottom-12 bottom-0 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-full py-4">
|
||||
<CardContent className="flex justify-between items-center">
|
||||
{loading ? (
|
||||
<div className="flex justify-between items-center animate-pulse w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Rank / Trophy */}
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200" />
|
||||
|
||||
{/* Pill button */}
|
||||
<div
|
||||
className={`rw-island-pill${islandOpen ? " open" : ""}`}
|
||||
onClick={() => !loading && setIslandOpen((o) => !o)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="w-12 h-12 rounded-full bg-gray-300" />
|
||||
<div className="rw-island-avatar">
|
||||
{leaderboard?.user_rank?.avatar_url ? (
|
||||
<span>
|
||||
{leaderboard?.user_rank?.name?.slice(0, 1).toUpperCase() ?? "?"}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{leaderboard?.user_rank?.name?.slice(0, 1).toUpperCase() ?? "?"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rw-island-info">
|
||||
<span className="rw-island-you">You</span>
|
||||
<span className="rw-island-name">
|
||||
{loading ? "Loading..." : (leaderboard?.user_rank?.name ?? "—")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="h-4 w-32 bg-gray-200 rounded" />
|
||||
</div>
|
||||
|
||||
{/* XP */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-10 bg-gray-200 rounded" />
|
||||
<div className="w-5 h-5 rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
{isTopThree ? (
|
||||
<img
|
||||
src={
|
||||
trophies[(leaderboard?.user_rank?.rank ?? Infinity) - 1]
|
||||
}
|
||||
alt={`trophy_${leaderboard?.user_rank?.rank ?? Infinity}`}
|
||||
className="w-12 h-12"
|
||||
{/* Live metric */}
|
||||
<div className="rw-island-metric">
|
||||
<span className="rw-island-metric-val">{userMetric}</span>
|
||||
{activeTab === "xp" ? (
|
||||
<Zap size={15} color="rgba(255,255,255,0.8)" />
|
||||
) : activeTab === "questions" ? (
|
||||
<LucideBadgeQuestionMark
|
||||
size={15}
|
||||
color="rgba(255,255,255,0.8)"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-12 text-center font-satoshi-bold text-white">
|
||||
{(leaderboard?.user_rank?.rank ?? Infinity) - 1}
|
||||
</span>
|
||||
)}
|
||||
<Avatar className={`p-6 bg-white`}>
|
||||
<AvatarImage src={leaderboard?.user_rank.avatar_url} />
|
||||
<AvatarFallback className=" font-satoshi-bold">
|
||||
{leaderboard?.user_rank.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="font-satoshi-bold text-white">
|
||||
{leaderboard?.user_rank.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-satoshi-medium text-white">
|
||||
{activeTab === "xp"
|
||||
? leaderboard?.user_rank.total_xp
|
||||
: activeTab === "questions"
|
||||
? "23"
|
||||
: "5"}
|
||||
</p>
|
||||
|
||||
{activeTab === "xp" ? (
|
||||
<Zap size={20} color="white" />
|
||||
) : activeTab === "questions" ? (
|
||||
<LucideBadgeQuestionMark size={20} color="white" />
|
||||
) : (
|
||||
<Flame size={20} color="white" />
|
||||
<Flame size={15} color="rgba(255,255,255,0.8)" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chevron */}
|
||||
<div className="rw-island-chevron">
|
||||
<ChevronDown size={13} color="white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -3,66 +3,174 @@ import { Home, BookOpen, Award, User, Video } from "lucide-react";
|
||||
import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar";
|
||||
import { AppSidebar } from "../../components/AppSidebar";
|
||||
|
||||
export function StudentLayout() {
|
||||
const navItems = [
|
||||
{ to: "/student/home", icon: Home, label: "Home" },
|
||||
{ to: "/student/practice", icon: BookOpen, label: "Practice" },
|
||||
{ to: "/student/lessons", icon: Video, label: "Lessons" },
|
||||
{ to: "/student/rewards", icon: Award, label: "Rewards" },
|
||||
{ to: "/student/profile", icon: User, label: "Profile" },
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
to: "/student/home",
|
||||
icon: Home,
|
||||
label: "Home",
|
||||
color: "#f97316",
|
||||
bg: "rgba(249,115,22,0.12)",
|
||||
},
|
||||
{
|
||||
to: "/student/practice",
|
||||
icon: BookOpen,
|
||||
label: "Practice",
|
||||
color: "#a855f7",
|
||||
bg: "rgba(168,85,247,0.12)",
|
||||
},
|
||||
{
|
||||
to: "/student/lessons",
|
||||
icon: Video,
|
||||
label: "Lessons",
|
||||
color: "#0891b2",
|
||||
bg: "rgba(8,145,178,0.12)",
|
||||
},
|
||||
{
|
||||
to: "/student/rewards",
|
||||
icon: Award,
|
||||
label: "Rewards",
|
||||
color: "#16a34a",
|
||||
bg: "rgba(22,163,74,0.12)",
|
||||
},
|
||||
{
|
||||
to: "/student/profile",
|
||||
icon: User,
|
||||
label: "Profile",
|
||||
color: "#e11d48",
|
||||
bg: "rgba(225,29,72,0.12)",
|
||||
},
|
||||
];
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap');
|
||||
|
||||
/* ── The floating island dock ── */
|
||||
.sl-dock-wrap {
|
||||
position: fixed;
|
||||
bottom: calc(1.25rem + env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
|
||||
/* Frosted pill */
|
||||
background: rgba(255, 251, 244, 0.72);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
border: 1.5px solid rgba(255,255,255,0.7);
|
||||
border-radius: 100px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0,0,0,0.12),
|
||||
0 2px 8px rgba(0,0,0,0.06),
|
||||
inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
|
||||
padding: 0.45rem 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
/* ── Each nav item ── */
|
||||
.sl-dock-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-radius: 100px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition:
|
||||
padding 0.35s cubic-bezier(0.34,1.56,0.64,1),
|
||||
gap 0.35s cubic-bezier(0.34,1.56,0.64,1),
|
||||
background 0.25s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sl-dock-item:active { transform: scale(0.91); }
|
||||
.sl-dock-item.active {
|
||||
padding: 0.5rem 1rem 0.5rem 0.75rem;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
/* ── Icon circle ── */
|
||||
.sl-dock-icon {
|
||||
width: 32px; height: 32px; flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: transparent;
|
||||
transition: background 0.25s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
.sl-dock-item.active .sl-dock-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ── Label (only visible when active) ── */
|
||||
.sl-dock-label {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.01em;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-width 0.35s cubic-bezier(0.34,1.56,0.64,1),
|
||||
opacity 0.25s ease 0.05s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sl-dock-item.active .sl-dock-label {
|
||||
max-width: 80px;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export function StudentLayout() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<style>{STYLES}</style>
|
||||
<div className="flex min-h-screen w-full overflow-x-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
<AppSidebar />
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<SidebarTrigger className="hidden md:block" />
|
||||
<main className="flex-1 pb-24 md:pb-0">
|
||||
{/* Extra bottom padding so content clears the floating dock */}
|
||||
<main className="flex-1 pb-32 md:pb-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 rounded-t-4xl pt-2 bg-white border-t border-gray-200 shadow-4xl z-20 md:hidden">
|
||||
<div className="max-w-7xl mx-auto px-2">
|
||||
<div className="flex justify-around items-center">
|
||||
{navItems.map((item) => (
|
||||
{/* ── Floating island dock (mobile only) ── */}
|
||||
<nav className="sl-dock-wrap md:hidden">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${
|
||||
isActive
|
||||
? "text-indigo-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`
|
||||
`sl-dock-item${isActive ? " active" : ""}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon
|
||||
size={24}
|
||||
className={`mb-1 transition-transform ${
|
||||
isActive ? "scale-110" : ""
|
||||
}`}
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isActive ? "font-semibold" : ""
|
||||
}`}
|
||||
<div
|
||||
className="sl-dock-icon"
|
||||
style={{ background: isActive ? item.bg : "transparent" }}
|
||||
>
|
||||
<item.icon
|
||||
size={18}
|
||||
strokeWidth={isActive ? 2.5 : 1.75}
|
||||
color={isActive ? item.color : "#94a3b8"}
|
||||
/>
|
||||
</div>
|
||||
<span className="sl-dock-label" style={{ color: item.color }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { api } from "../../../utils/api";
|
||||
import { useAuthStore } from "../../../stores/authStore";
|
||||
import type { PracticeSheet } from "../../../types/sheet";
|
||||
@ -10,223 +10,456 @@ import {
|
||||
CarouselItem,
|
||||
type CarouselApi,
|
||||
} from "../../../components/ui/carousel";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||
|
||||
// ─── Shared background dots (same subtle config as rest of app) ───────────────
|
||||
const DOTS = [
|
||||
{ size: 10, color: "#f97316", top: "8%", left: "5%", delay: "0s" },
|
||||
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
|
||||
{ size: 9, color: "#22c55e", top: "60%", left: "4%", delay: "0.6s" },
|
||||
{ size: 12, color: "#3b82f6", top: "12%", right: "4%", delay: "1.8s" },
|
||||
{ size: 7, color: "#f43f5e", top: "45%", right: "3%", delay: "0.9s" },
|
||||
{ size: 9, color: "#eab308", top: "75%", right: "6%", delay: "0.4s" },
|
||||
];
|
||||
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
.pt-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Blobs ── */
|
||||
.pt-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
|
||||
.pt-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:ptWobble1 14s ease-in-out infinite; }
|
||||
.pt-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:ptWobble2 16s ease-in-out infinite; }
|
||||
.pt-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:ptWobble1 18s ease-in-out infinite reverse; }
|
||||
.pt-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:ptWobble2 12s ease-in-out infinite; }
|
||||
|
||||
@keyframes ptWobble1 {
|
||||
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
|
||||
}
|
||||
@keyframes ptWobble2 {
|
||||
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
|
||||
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
|
||||
}
|
||||
|
||||
/* ── Floating dots ── */
|
||||
.pt-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:ptFloat 7s ease-in-out infinite; }
|
||||
@keyframes ptFloat {
|
||||
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||
50%{transform:translateY(-12px) rotate(180deg);}
|
||||
}
|
||||
|
||||
/* ── Inner scroll container ── */
|
||||
.pt-inner {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 580px; margin: 0 auto;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
display: flex; flex-direction: column; gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Pop-in animation ── */
|
||||
@keyframes ptPopIn {
|
||||
from { opacity:0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity:1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.pt-anim { animation: ptPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.pt-anim-1 { animation-delay: 0.05s; }
|
||||
.pt-anim-2 { animation-delay: 0.1s; }
|
||||
.pt-anim-3 { animation-delay: 0.15s; }
|
||||
.pt-anim-4 { animation-delay: 0.2s; }
|
||||
.pt-anim-5 { animation-delay: 0.25s; }
|
||||
|
||||
/* ── Header ── */
|
||||
.pt-header { animation: ptPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||
.pt-eyebrow {
|
||||
font-size: 0.65rem; font-weight: 800; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: #a855f7; margin-bottom: 0.3rem;
|
||||
}
|
||||
.pt-title {
|
||||
font-size: clamp(1.6rem, 5vw, 2.2rem); font-weight: 900;
|
||||
color: #1e1b4b; letter-spacing: -0.02em; line-height: 1.15;
|
||||
}
|
||||
.pt-desc {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.9rem; font-weight: 600; color: #9ca3af; margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* ── White card base ── */
|
||||
.pt-card {
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 24px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.04);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Stats row ── */
|
||||
.pt-stats-row {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem;
|
||||
}
|
||||
.pt-stat {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0.5rem; padding: 0.85rem 0.5rem;
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 20px;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
|
||||
}
|
||||
.pt-stat-icon {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pt-stat-icon.cyan { background: #cffafe; }
|
||||
.pt-stat-icon.purple { background: #f3e8ff; }
|
||||
.pt-stat-icon.amber { background: #fef3c7; }
|
||||
.pt-stat-value { font-size: 1.4rem; font-weight: 900; color: #1e1b4b; line-height: 1; }
|
||||
.pt-stat-label { font-size: 0.65rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #9ca3af; }
|
||||
|
||||
/* ── Loading skeleton ── */
|
||||
.pt-loading {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0.75rem; padding: 2rem;
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 24px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.04);
|
||||
}
|
||||
.pt-spinner { animation: ptSpin 0.8s linear infinite; }
|
||||
@keyframes ptSpin { to { transform: rotate(360deg); } }
|
||||
.pt-loading-text {
|
||||
font-size: 0.85rem; font-weight: 700; color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── Module carousel card ── */
|
||||
.pt-module-card {
|
||||
background: white; border: 2.5px solid #f3f4f6; border-radius: 24px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.04);
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
.pt-module-header {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
}
|
||||
.pt-section-badge {
|
||||
font-size: 0.65rem; font-weight: 800; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: #a855f7;
|
||||
background: #f3e8ff; border-radius: 100px; padding: 0.25rem 0.7rem;
|
||||
}
|
||||
.pt-module-title {
|
||||
font-size: 0.95rem; font-weight: 900; color: #1e1b4b; line-height: 1.3;
|
||||
}
|
||||
.pt-module-stats {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.6rem;
|
||||
}
|
||||
.pt-module-stat {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
|
||||
padding: 0.7rem 0.4rem;
|
||||
border: 2px solid #f3f4f6; border-radius: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.pt-module-stat-icon {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pt-module-stat-icon.cyan { background: #cffafe; }
|
||||
.pt-module-stat-icon.lime { background: #d9f99d; }
|
||||
.pt-module-stat-icon.amber { background: #fef3c7; }
|
||||
.pt-module-stat-value { font-size: 1rem; font-weight: 900; color: #1e1b4b; line-height: 1; }
|
||||
.pt-module-stat-label { font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #9ca3af; }
|
||||
|
||||
/* ── Dot indicator ── */
|
||||
.pt-dots {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.4rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.pt-dot-ind {
|
||||
border-radius: 100px; height: 7px;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
.pt-dot-ind.active { width: 20px; background: #a855f7; }
|
||||
.pt-dot-ind.inactive { width: 7px; background: #e5e7eb; }
|
||||
|
||||
/* ── Tip card ── */
|
||||
.pt-tip {
|
||||
display: flex; align-items: flex-start; gap: 0.75rem;
|
||||
}
|
||||
.pt-tip-emoji { font-size: 1.4rem; flex-shrink: 0; margin-top: 2px; }
|
||||
.pt-tip-text { font-size: 0.88rem; font-weight: 700; color: #374151; line-height: 1.5; }
|
||||
|
||||
/* ── Start button ── */
|
||||
.pt-start-btn {
|
||||
width: 100%;
|
||||
background: #f97316; color: white; border: none; border-radius: 100px;
|
||||
padding: 1.1rem; font-family: 'Nunito', sans-serif;
|
||||
font-size: 1rem; font-weight: 800; cursor: pointer;
|
||||
box-shadow: 0 6px 0 #c2560e, 0 8px 20px rgba(249,115,22,0.25);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
}
|
||||
.pt-start-btn:hover:not(:disabled) { transform:translateY(-2px); box-shadow:0 8px 0 #c2560e,0 12px 24px rgba(249,115,22,0.3); }
|
||||
.pt-start-btn:active:not(:disabled) { transform:translateY(3px); box-shadow:0 3px 0 #c2560e,0 4px 12px rgba(249,115,22,0.2); }
|
||||
.pt-start-btn:disabled { opacity:0.6; cursor:not-allowed; box-shadow:none; }
|
||||
|
||||
/* ── Error card ── */
|
||||
.pt-error {
|
||||
background: #fff5f5; border: 2.5px solid #fecaca; border-radius: 24px;
|
||||
padding: 1.5rem; text-align: center;
|
||||
font-size: 0.9rem; font-weight: 700; color: #ef4444;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Pretest = () => {
|
||||
const { setSheetId, setMode, storeDuration, setQuestionCount } =
|
||||
useExamConfigStore();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const { sheetId } = useParams<{ sheetId: string }>();
|
||||
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [practiceSheet, setPracticeSheet] = useState<PracticeSheet | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function handleStartTest(sheetId: string) {
|
||||
if (!sheetId) {
|
||||
console.error("Sheet ID is required to start the test.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSheetId(sheetId);
|
||||
function handleStartTest(id: string) {
|
||||
if (!id) return;
|
||||
setSheetId(id);
|
||||
setMode("SIMULATION");
|
||||
storeDuration(practiceSheet?.time_limit ?? 0);
|
||||
setQuestionCount(2);
|
||||
|
||||
navigate(`/student/practice/${sheetId}/test`, { replace: true });
|
||||
navigate(`/student/practice/${id}/test`, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
async function fetchPracticeSheet(sheetId: string) {
|
||||
async function fetchSheet(id: string) {
|
||||
const authStorage = localStorage.getItem("auth-storage");
|
||||
if (!authStorage) {
|
||||
console.error("authStorage not found in local storage");
|
||||
return;
|
||||
}
|
||||
if (!authStorage) return;
|
||||
const {
|
||||
state: { token },
|
||||
} = JSON.parse(authStorage);
|
||||
if (!token) {
|
||||
console.error("Token not found in authStorage");
|
||||
return;
|
||||
}
|
||||
const data = await api.getPracticeSheetById(token, sheetId);
|
||||
if (!token) return;
|
||||
const data = await api.getPracticeSheetById(token, id);
|
||||
setPracticeSheet(data);
|
||||
}
|
||||
fetchPracticeSheet(sheetId!);
|
||||
}, [sheetId]);
|
||||
fetchSheet(sheetId!);
|
||||
}, [sheetId, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!carouselApi) {
|
||||
return;
|
||||
}
|
||||
setCount(carouselApi.scrollSnapList().length);
|
||||
if (!carouselApi) return;
|
||||
setCurrent(carouselApi.selectedScrollSnap() + 1);
|
||||
carouselApi.on("select", () => {
|
||||
setCurrent(carouselApi.selectedScrollSnap() + 1);
|
||||
});
|
||||
carouselApi.on("select", () =>
|
||||
setCurrent(carouselApi.selectedScrollSnap() + 1),
|
||||
);
|
||||
}, [carouselApi]);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-4xl font-satoshi-bold">{practiceSheet?.title}</h1>
|
||||
<p className="text-lg font-satoshi text-gray-700">
|
||||
{practiceSheet?.description}
|
||||
</p>
|
||||
</header>
|
||||
<div className="pt-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="pt-blob pt-blob-1" />
|
||||
<div className="pt-blob pt-blob-2" />
|
||||
<div className="pt-blob pt-blob-3" />
|
||||
<div className="pt-blob pt-blob-4" />
|
||||
|
||||
{/* Floating dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="pt-dot"
|
||||
style={
|
||||
{
|
||||
width: d.size,
|
||||
height: d.size,
|
||||
background: d.color,
|
||||
top: d.top,
|
||||
left: d.left,
|
||||
right: d.right,
|
||||
animationDelay: d.delay,
|
||||
animationDuration: `${5 + i * 0.5}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="pt-inner">
|
||||
{/* ── Header ── */}
|
||||
<header className="pt-header">
|
||||
<p className="pt-eyebrow">📋 Practice Sheet</p>
|
||||
{practiceSheet ? (
|
||||
<section className="flex flex-col gap-6 rounded-4xl bg-white border p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Clock size={65} color="black" />
|
||||
<div>
|
||||
<h3 className="text-3xl font-satoshi-bold ">
|
||||
{practiceSheet?.time_limit}
|
||||
</h3>
|
||||
<p className="text-xl font-satoshi ">Minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Layers size={65} color="black" />
|
||||
<div>
|
||||
<h3 className="text-3xl font-satoshi-bold ">
|
||||
{practiceSheet?.modules.length}
|
||||
</h3>
|
||||
<p className="text-xl font-satoshi">Modules</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<CircleQuestionMark size={65} color="black" />
|
||||
<div>
|
||||
<h3 className="text-3xl font-satoshi-bold ">
|
||||
{practiceSheet?.questions_count}
|
||||
</h3>
|
||||
<p className="text-xl font-satoshi ">Questions</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<>
|
||||
<h1 className="pt-title">{practiceSheet.title}</h1>
|
||||
<p className="pt-desc">{practiceSheet.description}</p>
|
||||
</>
|
||||
) : (
|
||||
<section className="flex flex-col items-center gap-6 rounded-4xl bg-white border p-8">
|
||||
<div>
|
||||
<Loader size={30} className="transition animate-spin" />
|
||||
</div>
|
||||
</section>
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: "2rem",
|
||||
width: "70%",
|
||||
background: "#f3f4f6",
|
||||
borderRadius: 8,
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
height: "1rem",
|
||||
width: "50%",
|
||||
background: "#f3f4f6",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Carousel className="" setApi={setCarouselApi}>
|
||||
</header>
|
||||
|
||||
{/* ── At-a-glance stats ── */}
|
||||
{practiceSheet ? (
|
||||
<div className="pt-stats-row pt-anim pt-anim-1">
|
||||
<div className="pt-stat">
|
||||
<div className="pt-stat-icon cyan">
|
||||
<Clock size={22} color="#0891b2" />
|
||||
</div>
|
||||
<span className="pt-stat-value">{practiceSheet.time_limit}</span>
|
||||
<span className="pt-stat-label">Minutes</span>
|
||||
</div>
|
||||
<div className="pt-stat">
|
||||
<div className="pt-stat-icon purple">
|
||||
<Layers size={22} color="#9333ea" />
|
||||
</div>
|
||||
<span className="pt-stat-value">
|
||||
{practiceSheet.modules.length}
|
||||
</span>
|
||||
<span className="pt-stat-label">Modules</span>
|
||||
</div>
|
||||
<div className="pt-stat">
|
||||
<div className="pt-stat-icon amber">
|
||||
<CircleQuestionMark size={22} color="#d97706" />
|
||||
</div>
|
||||
<span className="pt-stat-value">
|
||||
{practiceSheet.questions_count}
|
||||
</span>
|
||||
<span className="pt-stat-label">Questions</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-loading pt-anim pt-anim-1">
|
||||
<Loader size={26} className="pt-spinner" color="#a855f7" />
|
||||
<p className="pt-loading-text">Loading sheet details...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Module carousel ── */}
|
||||
<div className="pt-anim pt-anim-2">
|
||||
<Carousel setApi={setCarouselApi}>
|
||||
<CarouselContent>
|
||||
{practiceSheet ? (
|
||||
practiceSheet.modules.length > 0 ? (
|
||||
practiceSheet.modules.map((module, index) => (
|
||||
<CarouselItem key={index} className="">
|
||||
<section className="flex flex-col gap-6 rounded-4xl p-8 bg-white border">
|
||||
<h1 className="text-2xl font-satoshi-bold">
|
||||
<CarouselItem key={index}>
|
||||
<div className="pt-module-card">
|
||||
<div className="pt-module-header">
|
||||
<span className="pt-section-badge">
|
||||
Section {Math.floor(index / 2) + 1}
|
||||
</h1>
|
||||
<p className="text-lg font-satoshi text-gray-700">
|
||||
{module.title}
|
||||
</p>
|
||||
<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="w-fit bg-cyan-100 p-2 rounded-full">
|
||||
<Clock size={30} color="oklch(60.9% 0.126 221.723)" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<h3 className="text-xl font-satoshi-bold ">
|
||||
<p className="pt-module-title">{module.title}</p>
|
||||
<div className="pt-module-stats">
|
||||
<div className="pt-module-stat">
|
||||
<div className="pt-module-stat-icon cyan">
|
||||
<Clock size={18} color="#0891b2" />
|
||||
</div>
|
||||
<span className="pt-module-stat-value">
|
||||
{module.duration}
|
||||
</h3>
|
||||
<p className="text-md font-satoshi ">Minutes</p>
|
||||
</span>
|
||||
<span className="pt-module-stat-label">Min</span>
|
||||
</div>
|
||||
<div className="pt-module-stat">
|
||||
<div className="pt-module-stat-icon lime">
|
||||
<CircleQuestionMark size={18} color="#65a30d" />
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-fit bg-lime-100 p-2 rounded-full">
|
||||
<CircleQuestionMark
|
||||
size={30}
|
||||
color="oklch(64.8% 0.2 131.684)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<h3 className="text-xl font-satoshi-bold ">
|
||||
<span className="pt-module-stat-value">
|
||||
{module.questions.length}
|
||||
</h3>
|
||||
<p className="text-md font-satoshi">Questions</p>
|
||||
</span>
|
||||
<span className="pt-module-stat-label">
|
||||
Questions
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-module-stat">
|
||||
<div className="pt-module-stat-icon amber">
|
||||
<Tag size={18} color="#d97706" />
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-fit bg-amber-100 p-2 rounded-full">
|
||||
<Tag size={30} color="oklch(66.6% 0.179 58.318)" />
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<h3 className="text-xl font-satoshi-bold ">
|
||||
<span
|
||||
className="pt-module-stat-value"
|
||||
style={{ fontSize: "0.85rem" }}
|
||||
>
|
||||
{module.section}
|
||||
</h3>
|
||||
<p className="text-md font-satoshi">Type</p>
|
||||
</span>
|
||||
<span className="pt-module-stat-label">Type</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</CarouselItem>
|
||||
))
|
||||
) : (
|
||||
<CarouselItem>
|
||||
<section className="w-full rounded-4xl p-8 bg-red-100 border border-red-300">
|
||||
<h1 className="text-lg text-center font-satoshi-bold text-red-500">
|
||||
No modules available.
|
||||
</h1>
|
||||
</section>
|
||||
<div className="pt-error">
|
||||
😕 No modules available for this sheet.
|
||||
</div>
|
||||
</CarouselItem>
|
||||
)
|
||||
) : (
|
||||
<CarouselItem>
|
||||
<section className="flex flex-col w-full rounded-4xl p-8 bg-yellow-100 border items-center justify-between gap-4">
|
||||
<div>
|
||||
<Loader size={30} className="transition animate-spin" />
|
||||
<div className="pt-loading">
|
||||
<Loader size={26} className="pt-spinner" color="#a855f7" />
|
||||
<p className="pt-loading-text">Loading modules...</p>
|
||||
</div>
|
||||
<h1 className="text-center text-xl font-satoshi-bold text-yellow-500">
|
||||
Loading...
|
||||
</h1>
|
||||
</section>
|
||||
</CarouselItem>
|
||||
)}
|
||||
</CarouselContent>
|
||||
|
||||
<div className="flex justify-center mt-4">
|
||||
{practiceSheet?.modules.map((_, index) => (
|
||||
{/* Dot indicator */}
|
||||
{practiceSheet && practiceSheet.modules.length > 1 && (
|
||||
<div className="pt-dots">
|
||||
{practiceSheet.modules.map((_, i) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 mx-1 rounded-full ${
|
||||
index + 1 === current ? "bg-indigo-500" : "bg-gray-300"
|
||||
}`}
|
||||
></div>
|
||||
key={i}
|
||||
className={`pt-dot-ind ${i + 1 === current ? "active" : "inactive"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Carousel>
|
||||
<section className="w-full rounded-4xl p-8 bg-white border flex flex-col justify-between gap-4">
|
||||
<h1 className="text-lg font-satoshi-bold ">
|
||||
This practice sheet will help you prepare for the SAT. Take your time
|
||||
and do your best!
|
||||
</h1>
|
||||
</section>
|
||||
<Button
|
||||
</div>
|
||||
|
||||
{/* ── Encouragement tip ── */}
|
||||
<div className="pt-card pt-anim pt-anim-3">
|
||||
<div className="pt-tip">
|
||||
<span className="pt-tip-emoji">💪</span>
|
||||
<p className="pt-tip-text">
|
||||
Take your time, read each question carefully, and do your best.
|
||||
Every practice run brings you closer to your goal!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Start button ── */}
|
||||
<button
|
||||
className="pt-start-btn pt-anim pt-anim-4"
|
||||
onClick={() => handleStartTest(practiceSheet?.id!)}
|
||||
variant="outline"
|
||||
className="font-satoshi rounded-3xl w-full text-lg py-8 bg-linear-to-br from-indigo-500 to-indigo-600 text-white active:bg-linear-to-br active:from-indigo-600 active:to-indigo-700 active:translate-y-1"
|
||||
disabled={!practiceSheet}
|
||||
>
|
||||
{practiceSheet ? (
|
||||
"Start Test"
|
||||
<>Start Test →</>
|
||||
) : (
|
||||
<div>
|
||||
<Loader size={60} className="transition animate-spin" />
|
||||
</div>
|
||||
<Loader size={20} className="pt-spinner" />
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,119 +1,339 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useResults } from "../../../stores/useResults";
|
||||
import { LucideArrowLeft } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||
|
||||
const XPGainedCard = ({
|
||||
results,
|
||||
}: {
|
||||
results?: {
|
||||
xp_gained: number;
|
||||
total_xp: number;
|
||||
current_level_start: number;
|
||||
next_level_threshold: number;
|
||||
current_level: number;
|
||||
};
|
||||
}) => {
|
||||
const [displayXP, setDisplayXP] = useState(0);
|
||||
// ─── Shared styles injected once ─────────────────────────────────────────────
|
||||
const STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap');
|
||||
|
||||
.results-screen {
|
||||
min-height: 100vh;
|
||||
background: #fffbf4;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
padding: 0 0 3rem 0;
|
||||
}
|
||||
|
||||
/* ── Blobs ── */
|
||||
.r-blob { position: fixed; pointer-events: none; z-index: 0; }
|
||||
.r-blob-1 {
|
||||
width: 260px; height: 260px; background: #fde68a;
|
||||
top: -80px; left: -80px;
|
||||
border-radius: 60% 40% 70% 30% / 50% 60% 40% 50%;
|
||||
animation: rWobble1 6s ease-in-out infinite;
|
||||
}
|
||||
.r-blob-2 {
|
||||
width: 200px; height: 200px; background: #a5f3c0;
|
||||
bottom: -60px; left: 8%;
|
||||
border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%;
|
||||
animation: rWobble2 7s ease-in-out infinite;
|
||||
}
|
||||
.r-blob-3 {
|
||||
width: 220px; height: 220px; background: #fbcfe8;
|
||||
top: 12%; right: -60px;
|
||||
border-radius: 70% 30% 50% 50% / 40% 60% 40% 60%;
|
||||
animation: rWobble1 8s ease-in-out infinite reverse;
|
||||
}
|
||||
.r-blob-4 {
|
||||
width: 160px; height: 160px; background: #bfdbfe;
|
||||
bottom: 15%; right: 3%;
|
||||
border-radius: 50% 50% 30% 70% / 60% 40% 60% 40%;
|
||||
animation: rWobble2 5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rWobble1 {
|
||||
0%,100% { border-radius:60% 40% 70% 30%/50% 60% 40% 50%; transform:translate(0,0) rotate(0deg); }
|
||||
50% { border-radius:40% 60% 30% 70%/60% 40% 60% 40%; transform:translate(12px,16px) rotate(8deg); }
|
||||
}
|
||||
@keyframes rWobble2 {
|
||||
0%,100% { border-radius:40% 60% 30% 70%/60% 40% 60% 40%; transform:translate(0,0) rotate(0deg); }
|
||||
50% { border-radius:60% 40% 70% 30%/40% 60% 40% 60%; transform:translate(-10px,12px) rotate(-6deg); }
|
||||
}
|
||||
|
||||
/* ── Floating dots ── */
|
||||
.r-dot {
|
||||
position: fixed; border-radius: 50%; pointer-events: none; z-index: 0;
|
||||
animation: rFloat 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rFloat {
|
||||
0%,100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-14px) rotate(180deg); }
|
||||
}
|
||||
|
||||
/* ── Content wrapper ── */
|
||||
.results-inner {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 2rem;
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.results-header {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
.results-back-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
border: 2px solid #c084fc;
|
||||
background: linear-gradient(135deg, #c084fc, #a855f7);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
box-shadow: 0 4px 0 #7e22ce55;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.results-back-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 0 #7e22ce55; }
|
||||
.results-back-btn:active { transform: translateY(2px); box-shadow: 0 2px 0 #7e22ce55; }
|
||||
.results-title {
|
||||
font-size: 2rem; font-weight: 900; color: #1e1b4b;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ── Mode badge ── */
|
||||
.mode-badge {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
background: white; border: 2.5px solid #e9d5ff;
|
||||
border-radius: 16px; padding: 0.75rem 1.1rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.05s both;
|
||||
}
|
||||
.mode-badge-text { font-size: 0.85rem; font-weight: 700; color: #7e22ce; }
|
||||
.mode-badge-sub { font-size: 0.78rem; font-weight: 600; color: #a78bfa; }
|
||||
|
||||
/* ── Hero congratulations banner ── */
|
||||
.congrats-banner {
|
||||
background: white;
|
||||
border: 2.5px solid #f3f4f6;
|
||||
border-radius: 24px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.05);
|
||||
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.1s both;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
|
||||
}
|
||||
.congrats-emoji {
|
||||
font-size: 3rem;
|
||||
animation: rBounce 2s ease-in-out infinite;
|
||||
display: block; margin-bottom: 0.25rem;
|
||||
}
|
||||
@keyframes rBounce {
|
||||
0%,100% { transform: translateY(0) rotate(-4deg); }
|
||||
50% { transform: translateY(-10px) rotate(4deg); }
|
||||
}
|
||||
.congrats-title {
|
||||
font-size: 1.4rem; font-weight: 900; color: #1e1b4b;
|
||||
}
|
||||
.congrats-title span { color: #f97316; }
|
||||
.congrats-sub {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.85rem; font-weight: 600; color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── XP ring section ── */
|
||||
.xp-section {
|
||||
display: flex; justify-content: center;
|
||||
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.15s both;
|
||||
}
|
||||
|
||||
/* ── Stat cards grid ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.2s both;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
border: 2.5px solid #f3f4f6;
|
||||
border-radius: 20px;
|
||||
padding: 1.1rem 1rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.04);
|
||||
display: flex; flex-direction: column; gap: 0.2rem;
|
||||
}
|
||||
.stat-card-label {
|
||||
font-size: 0.65rem; font-weight: 700;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
}
|
||||
.stat-card-value {
|
||||
font-size: 1.9rem; font-weight: 900; color: #1e1b4b;
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-card-sub {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.75rem; font-weight: 600; color: #d1d5db;
|
||||
}
|
||||
/* Accent colours per card */
|
||||
.stat-card.orange { border-color: #fed7aa; }
|
||||
.stat-card.orange .stat-card-value { color: #f97316; }
|
||||
.stat-card.green { border-color: #bbf7d0; }
|
||||
.stat-card.green .stat-card-value { color: #16a34a; }
|
||||
.stat-card.purple { border-color: #e9d5ff; }
|
||||
.stat-card.purple .stat-card-value { color: #9333ea; }
|
||||
.stat-card.blue { border-color: #bfdbfe; }
|
||||
.stat-card.blue .stat-card-value { color: #2563eb; }
|
||||
|
||||
/* ── Improvement tip card ── */
|
||||
.tip-card {
|
||||
background: white;
|
||||
border: 2.5px solid #f3f4f6;
|
||||
border-radius: 20px;
|
||||
padding: 1.1rem 1.25rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.04);
|
||||
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.25s both;
|
||||
}
|
||||
.tip-card-header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.tip-card-title {
|
||||
font-size: 0.95rem; font-weight: 900; color: #1e1b4b;
|
||||
}
|
||||
.tip-chips {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||||
}
|
||||
.tip-chip {
|
||||
background: #fafafa; border: 2px solid #f3f4f6;
|
||||
border-radius: 100px; padding: 0.35rem 0.85rem;
|
||||
font-size: 0.75rem; font-weight: 700; color: #374151;
|
||||
display: flex; align-items: center; gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* ── Done button ── */
|
||||
.done-btn {
|
||||
width: 100%;
|
||||
background: #f97316;
|
||||
color: white; border: none;
|
||||
border-radius: 100px;
|
||||
padding: 1rem 2.5rem;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 1rem; font-weight: 800;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 0 #c2560e, 0 8px 20px rgba(249,115,22,0.25);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.35s both;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.done-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 0 #c2560e, 0 12px 24px rgba(249,115,22,0.3); }
|
||||
.done-btn:active { transform: translateY(3px); box-shadow: 0 3px 0 #c2560e, 0 4px 12px rgba(249,115,22,0.2); }
|
||||
|
||||
@keyframes rPopIn {
|
||||
from { opacity:0; transform: scale(0.88) translateY(12px); }
|
||||
to { opacity:1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Animated counter ─────────────────────────────────────────────────────────
|
||||
function useCountUp(target: number, duration = 900) {
|
||||
const [val, setVal] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!results?.xp_gained) return;
|
||||
|
||||
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 * results.xp_gained));
|
||||
if (t < 1) requestAnimationFrame(animate);
|
||||
if (!target) return;
|
||||
let start: number | null = null;
|
||||
const tick = (t: number) => {
|
||||
if (!start) start = t;
|
||||
const p = Math.min((t - start) / duration, 1);
|
||||
setVal(Math.floor(p * target));
|
||||
if (p < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}, [target]);
|
||||
return val;
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [results?.xp_gained]);
|
||||
// ─── Floating dots config (shared) ───────────────────────────────────────────
|
||||
const DOTS = [
|
||||
{ size: 12, color: "#f97316", top: "18%", left: "12%", delay: "0s" },
|
||||
{ size: 8, color: "#a855f7", top: "33%", left: "5%", delay: "1s" },
|
||||
{ size: 10, color: "#22c55e", top: "62%", left: "15%", delay: "0.5s" },
|
||||
{ size: 14, color: "#3b82f6", top: "22%", right: "10%", delay: "1.5s" },
|
||||
{ size: 8, color: "#f43f5e", top: "52%", right: "6%", delay: "0.8s" },
|
||||
{ size: 10, color: "#eab308", top: "72%", right: "18%", delay: "0.3s" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>XP</CardTitle>
|
||||
<CardDescription>How much did you improve?</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">+{displayXP} XP</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Targeted static results ──────────────────────────────────────────────────
|
||||
// ─── Targeted 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);
|
||||
const displayXP = useCountUp(gainedXP);
|
||||
|
||||
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);
|
||||
setUserXp(previousXP);
|
||||
}, []);
|
||||
|
||||
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" />
|
||||
<div className="results-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="r-blob r-blob-1" />
|
||||
<div className="r-blob r-blob-2" />
|
||||
<div className="r-blob r-blob-3" />
|
||||
<div className="r-blob r-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="r-dot"
|
||||
style={
|
||||
{
|
||||
width: d.size,
|
||||
height: d.size,
|
||||
background: d.color,
|
||||
top: d.top,
|
||||
left: d.left,
|
||||
right: d.right,
|
||||
animationDelay: d.delay,
|
||||
animationDuration: `${3.5 + i * 0.4}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="results-inner">
|
||||
{/* Header */}
|
||||
<header className="results-header">
|
||||
<button className="results-back-btn" onClick={onFinish}>
|
||||
<LucideArrowLeft size={18} color="white" />
|
||||
</button>
|
||||
<h1 className="text-3xl font-satoshi-bold">Results</h1>
|
||||
<h1 className="results-title">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.
|
||||
{/* Mode badge */}
|
||||
<div className="mode-badge">
|
||||
<span style={{ fontSize: "1.4rem" }}>🎯</span>
|
||||
<div>
|
||||
<p className="mode-badge-text">Targeted Mode Complete!</p>
|
||||
<p className="mode-badge-sub">
|
||||
You answered all questions correctly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="w-full flex items-center justify-center">
|
||||
{/* Congrats banner */}
|
||||
<div className="congrats-banner">
|
||||
<span className="congrats-emoji">🏆</span>
|
||||
<p className="congrats-title">
|
||||
Nailed it, <span>champ!</span>
|
||||
</p>
|
||||
<p className="congrats-sub">Perfect run — every question down.</p>
|
||||
</div>
|
||||
|
||||
{/* XP ring */}
|
||||
<div className="xp-section">
|
||||
<CircularLevelProgress
|
||||
previousXP={previousXP}
|
||||
gainedXP={gainedXP}
|
||||
@ -121,45 +341,50 @@ const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
|
||||
levelMaxXP={levelMaxXP}
|
||||
level={currentLevel}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>XP</CardTitle>
|
||||
<CardDescription>How much did you improve?</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">+{displayXP} XP</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{/* Stats grid */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card orange">
|
||||
<span className="stat-card-label">⚡ XP Gained</span>
|
||||
<span className="stat-card-value">+{displayXP}</span>
|
||||
<span className="stat-card-sub">experience points</span>
|
||||
</div>
|
||||
<div className="stat-card green">
|
||||
<span className="stat-card-label">🎯 Score</span>
|
||||
<span className="stat-card-value">{TARGETED_SCORE}</span>
|
||||
<span className="stat-card-sub">total points</span>
|
||||
</div>
|
||||
<div className="stat-card purple">
|
||||
<span className="stat-card-label">✅ Accuracy</span>
|
||||
<span className="stat-card-value">100%</span>
|
||||
<span className="stat-card-sub">all correct</span>
|
||||
</div>
|
||||
<div className="stat-card blue">
|
||||
<span className="stat-card-label">🔥 Streak</span>
|
||||
<span className="stat-card-value">Perfect</span>
|
||||
<span className="stat-card-sub">no mistakes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score</CardTitle>
|
||||
<CardDescription>Total score you achieved.</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">{TARGETED_SCORE}</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{/* Tip card */}
|
||||
<div className="tip-card">
|
||||
<div className="tip-card-header">
|
||||
<span style={{ fontSize: "1.2rem" }}>🚀</span>
|
||||
<span className="tip-card-title">Keep the momentum going!</span>
|
||||
</div>
|
||||
<div className="tip-chips">
|
||||
<div className="tip-chip">📖 Review mistakes</div>
|
||||
<div className="tip-chip">⏱️ Try timed mode</div>
|
||||
<div className="tip-chip">🎯 Next topic</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 className="done-btn" onClick={onFinish}>
|
||||
Done ✓
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -168,40 +393,91 @@ export const Results = () => {
|
||||
const navigate = useNavigate();
|
||||
const results = useResults((s) => s.results);
|
||||
const clearResults = useResults((s) => s.clearResults);
|
||||
|
||||
const { setUserXp, payload } = useExamConfigStore();
|
||||
const isTargeted = payload?.mode === "TARGETED";
|
||||
|
||||
useEffect(() => {
|
||||
if (results) setUserXp(results?.total_xp);
|
||||
if (results) setUserXp(results.total_xp);
|
||||
}, [results]);
|
||||
|
||||
function handleFinishExam() {
|
||||
useExamConfigStore.getState().clearPayload();
|
||||
clearResults();
|
||||
navigate(`/student/home`);
|
||||
navigate("/student/home");
|
||||
}
|
||||
|
||||
// ── Targeted mode: show static screen ──────────────────────────────────────
|
||||
if (isTargeted) {
|
||||
return <TargetedResults onFinish={handleFinishExam} />;
|
||||
}
|
||||
if (isTargeted) return <TargetedResults onFinish={handleFinishExam} />;
|
||||
|
||||
// ── Standard mode ──────────────────────────────────────────────────────────
|
||||
// ── Standard mode values ───────────────────────────────────────────────────
|
||||
const previousXP = results ? results.total_xp - results.xp_gained : 0;
|
||||
const accuracy =
|
||||
results && results.total_questions > 0
|
||||
? Math.round((results.correct_count / results.total_questions) * 100)
|
||||
: 0;
|
||||
const displayXP = useCountUp(results?.xp_gained ?? 0);
|
||||
const displayScore = useCountUp(results?.score ?? 0);
|
||||
|
||||
// Motivational headline based on accuracy
|
||||
const headline =
|
||||
accuracy >= 90
|
||||
? { emoji: "🏆", text: "Absolutely crushing it!" }
|
||||
: accuracy >= 70
|
||||
? { emoji: "🎉", text: "Solid work, keep going!" }
|
||||
: accuracy >= 50
|
||||
? { emoji: "💪", text: "Good effort, room to grow!" }
|
||||
: { emoji: "📚", text: "Every attempt makes you better!" };
|
||||
|
||||
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={() => handleFinishExam()}
|
||||
className="p-2 rounded-full border border-purple-400 bg-linear-to-br from-purple-400 to-purple-500"
|
||||
>
|
||||
<LucideArrowLeft size={20} color="white" />
|
||||
<div className="results-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Blobs */}
|
||||
<div className="r-blob r-blob-1" />
|
||||
<div className="r-blob r-blob-2" />
|
||||
<div className="r-blob r-blob-3" />
|
||||
<div className="r-blob r-blob-4" />
|
||||
|
||||
{/* Dots */}
|
||||
{DOTS.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="r-dot"
|
||||
style={
|
||||
{
|
||||
width: d.size,
|
||||
height: d.size,
|
||||
background: d.color,
|
||||
top: d.top,
|
||||
left: d.left,
|
||||
right: d.right,
|
||||
animationDelay: d.delay,
|
||||
animationDuration: `${3.5 + i * 0.4}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="results-inner">
|
||||
{/* Header */}
|
||||
<header className="results-header">
|
||||
<button className="results-back-btn" onClick={handleFinishExam}>
|
||||
<LucideArrowLeft size={18} color="white" />
|
||||
</button>
|
||||
<h1 className="text-3xl font-satoshi-bold">Results</h1>
|
||||
<h1 className="results-title">Results</h1>
|
||||
</header>
|
||||
<section className="w-full flex items-center justify-center">
|
||||
|
||||
{/* Congrats banner — dynamic */}
|
||||
<div className="congrats-banner">
|
||||
<span className="congrats-emoji">{headline.emoji}</span>
|
||||
<p className="congrats-title">
|
||||
{headline.text.split(" ").slice(0, -1).join(" ")}{" "}
|
||||
<span>{headline.text.split(" ").slice(-1)}</span>
|
||||
</p>
|
||||
<p className="congrats-sub">Here's how you performed today</p>
|
||||
</div>
|
||||
|
||||
{/* XP ring */}
|
||||
<div className="xp-section">
|
||||
{results && (
|
||||
<CircularLevelProgress
|
||||
previousXP={previousXP}
|
||||
@ -211,41 +487,55 @@ export const Results = () => {
|
||||
level={results.current_level}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<XPGainedCard results={results} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score</CardTitle>
|
||||
<CardDescription>Total score you achieved.</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">{results?.score}</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Accuracy</CardTitle>
|
||||
<CardDescription>How many did you answer correct?</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">
|
||||
{results && results.total_questions > 0
|
||||
? `${Math.round(
|
||||
(results.correct_count / results.total_questions) * 100,
|
||||
)}%`
|
||||
: "—"}
|
||||
</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>How do you improve?</CardTitle>
|
||||
<CardDescription>
|
||||
Your score is good, but you can do better!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</main>
|
||||
{/* Stats grid */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card orange">
|
||||
<span className="stat-card-label">⚡ XP Gained</span>
|
||||
<span className="stat-card-value">+{displayXP}</span>
|
||||
<span className="stat-card-sub">experience points</span>
|
||||
</div>
|
||||
<div className="stat-card green">
|
||||
<span className="stat-card-label">🎯 Score</span>
|
||||
<span className="stat-card-value">{displayScore}</span>
|
||||
<span className="stat-card-sub">total points</span>
|
||||
</div>
|
||||
<div className={`stat-card ${accuracy >= 70 ? "purple" : "blue"}`}>
|
||||
<span className="stat-card-label">✅ Accuracy</span>
|
||||
<span className="stat-card-value">{accuracy}%</span>
|
||||
<span className="stat-card-sub">
|
||||
{results?.correct_count ?? 0} of {results?.total_questions ?? 0}{" "}
|
||||
correct
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-card blue">
|
||||
<span className="stat-card-label">❌ Missed</span>
|
||||
<span className="stat-card-value">
|
||||
{results ? results.total_questions - results.correct_count : 0}
|
||||
</span>
|
||||
<span className="stat-card-sub">questions to review</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tip card */}
|
||||
<div className="tip-card">
|
||||
<div className="tip-card-header">
|
||||
<span style={{ fontSize: "1.2rem" }}>💡</span>
|
||||
<span className="tip-card-title">How to improve faster</span>
|
||||
</div>
|
||||
<div className="tip-chips">
|
||||
<div className="tip-chip">📖 Review wrong answers</div>
|
||||
<div className="tip-chip">🔁 Retry missed questions</div>
|
||||
<div className="tip-chip">⏱️ Work on pacing</div>
|
||||
<div className="tip-chip">📈 Track your trends</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="done-btn" onClick={handleFinishExam}>
|
||||
Done ✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -28,7 +28,7 @@ interface SatExamState {
|
||||
getRemainingTime: () => number;
|
||||
}
|
||||
|
||||
const BREAK_DURATION = 30; // seconds
|
||||
const BREAK_DURATION = 600; // seconds
|
||||
|
||||
export const useSatExam = create<SatExamState>()(
|
||||
persist(
|
||||
|
||||
Reference in New Issue
Block a user