Compare commits
12 Commits
feat/ui
...
4df5707ebd
| Author | SHA1 | Date | |
|---|---|---|---|
| 4df5707ebd | |||
| e7db0a5d31 | |||
| 7dfa73c397 | |||
| c7f0183956 | |||
| f64d2cac4a | |||
| 894863c196 | |||
| d56ea14a22 | |||
| be63ca5ed2 | |||
| a48a50ae77 | |||
| f054c7179b | |||
| 65dbe99647 | |||
| 76d2108aec |
7170
package-lock.json
generated
Normal file
7170
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -31,6 +31,7 @@
|
|||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -71,6 +71,9 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 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:
|
zustand:
|
||||||
specifier: ^5.0.9
|
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))
|
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:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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:
|
vite@7.3.0:
|
||||||
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@ -4385,6 +4394,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.3
|
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):
|
vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.2
|
esbuild: 0.27.2
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import { TargetedPractice } from "./pages/student/targeted-practice/page";
|
|||||||
import { Drills } from "./pages/student/drills/page";
|
import { Drills } from "./pages/student/drills/page";
|
||||||
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
||||||
import { Analytics } from "./pages/student/Analytics";
|
import { Analytics } from "./pages/student/Analytics";
|
||||||
|
import { QuestMap } from "./pages/student/QuestMap";
|
||||||
|
import ErrorPage from "./pages/ErrorPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -58,6 +60,10 @@ function App() {
|
|||||||
path: "analytics",
|
path: "analytics",
|
||||||
element: <Analytics />,
|
element: <Analytics />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "quests",
|
||||||
|
element: <QuestMap />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "practice/:sheetId",
|
path: "practice/:sheetId",
|
||||||
element: <Pretest />,
|
element: <Pretest />,
|
||||||
|
|||||||
@ -16,201 +16,263 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Home,
|
Home,
|
||||||
Video,
|
Video,
|
||||||
User,
|
|
||||||
Target,
|
Target,
|
||||||
Zap,
|
Zap,
|
||||||
Trophy,
|
Trophy,
|
||||||
LayoutGrid,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import logo from "../assets/ed_logo1.png";
|
import logo from "../assets/ed_logo1.png";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink, useNavigate } from "react-router-dom";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(false);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar className="border-r bg-black text-white">
|
<Sidebar
|
||||||
|
variant="floating"
|
||||||
|
className="pointer-events-none border-0 bg-transparent px-0 py-0"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto fixed inset-y-2 left-2 flex w-64 flex-col overflow-hidden rounded-[1.75rem] bg-[rgba(249,240,255,0.82)] shadow-[0_18px_45px_rgba(15,23,42,0.16)] transition-colors duration-300">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(168,85,247,0.18),transparent_55%),radial-gradient(circle_at_bottom,rgba(249,115,22,0.1),transparent_55%)]" />
|
||||||
|
|
||||||
|
<div className="relative flex h-full flex-col">
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
<SidebarHeader>
|
<SidebarHeader className="px-3 pb-4 pt-1">
|
||||||
<div className="flex items-center justify-between px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer">
|
<div className="flex items-center justify-start gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 rounded-2xl px-2 py-2">
|
||||||
<div className="flex rounded-md w-10 h-10 border overflow-hidden">
|
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-linear-to-br from-purple-400 to-purple-500 shadow-[0_6px_18px_rgba(168,85,247,0.55)]">
|
||||||
<img
|
<img
|
||||||
src={logo}
|
src={logo}
|
||||||
className="w-full h-full object-cover object-left"
|
className="h-full w-full object-cover object-left"
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col text-sm">
|
<div className="flex flex-col text-sm">
|
||||||
<span className="font-satoshi-medium text-black">
|
<span className="font-satoshi-medium text-slate-900">
|
||||||
Edbridge Scholars
|
Edbridge Scholars
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 font-satoshi">
|
<span className="font-satoshi text-xs text-slate-400">
|
||||||
Student
|
Student
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={16} />
|
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
{/* CONTENT */}
|
{/* CONTENT */}
|
||||||
<SidebarContent>
|
<SidebarContent className="px-1">
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="text-gray-400 font-satoshi">
|
<SidebarGroupLabel className="px-2 text-[0.7rem] font-satoshi tracking-[0.16em] text-slate-400">
|
||||||
Platform
|
PLATFORM
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
|
|
||||||
<SidebarMenu>
|
<SidebarMenu className="mt-1 space-y-1.5">
|
||||||
|
{/* HOME */}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
|
||||||
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/home"
|
to="/student/home"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 text-sm font-satoshi ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-zinc-800 text-white"
|
? "text-slate-900"
|
||||||
: "text-zinc-400 hover:bg-zinc-800"
|
: "text-slate-500 group-hover:text-slate-900"
|
||||||
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Home size={18} className="text-black" />
|
{({ isActive }) => (
|
||||||
<span className="font-satoshi text-black">Home</span>
|
<>
|
||||||
|
<Home
|
||||||
|
size={18}
|
||||||
|
className={
|
||||||
|
isActive ? "text-orange-400" : "text-slate-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Home</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
<SidebarMenuItem>
|
{/* PRACTICE */}
|
||||||
<SidebarMenuButton
|
<SidebarMenuItem
|
||||||
className="cursor-pointer"
|
onMouseEnter={() => setOpen(true)}
|
||||||
asChild
|
onMouseLeave={() => setOpen(false)}
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
>
|
>
|
||||||
<div>
|
<SidebarMenuButton
|
||||||
<BookOpen size={18} className="text-black" />
|
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
|
||||||
<span className="font-satoshi text-black">Practice</span>
|
asChild
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
to="/student/practice"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 text-sm font-satoshi ${
|
||||||
|
isActive
|
||||||
|
? "text-slate-900"
|
||||||
|
: "text-slate-500 group-hover:text-slate-900"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<BookOpen
|
||||||
|
size={18}
|
||||||
|
className={
|
||||||
|
isActive ? "text-purple-500" : "text-slate-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Practice</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={16}
|
size={16}
|
||||||
className={`ml-auto transition-transform ${
|
className={`ml-auto text-slate-400 transition-transform ${
|
||||||
open ? "rotate-180" : ""
|
open ? "rotate-180" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
{open && (
|
{open && (
|
||||||
<SidebarMenuSub className="space-y-3 mt-2">
|
<SidebarMenuSub className="mt-2 space-y-1.5 pl-3">
|
||||||
<NavLink
|
|
||||||
to="/student/practice"
|
|
||||||
className="text-black text-sm flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<LayoutGrid size={18} className="text-black" />
|
|
||||||
<span className="font-satoshi text-black">
|
|
||||||
Practice your way
|
|
||||||
</span>
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/practice/targeted-practice"
|
to="/student/practice/targeted-practice"
|
||||||
className="text-black text-sm flex items-center gap-3"
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? "bg-white text-slate-900"
|
||||||
|
: "text-slate-500 hover:bg-white hover:text-slate-900"
|
||||||
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Target size={18} className="text-black" />
|
<Target size={18} className="text-slate-400" />
|
||||||
<span className="font-satoshi text-black">
|
<span>Targeted Practice</span>
|
||||||
Targeted Practice
|
|
||||||
</span>
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/practice/drills"
|
to="/student/practice/drills"
|
||||||
className="text-black text-sm flex items-center gap-3"
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? "bg-white text-slate-900"
|
||||||
|
: "text-slate-500 hover:bg-white hover:text-slate-900"
|
||||||
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Zap size={18} className="text-black" />
|
<Zap size={18} className="text-slate-400" />
|
||||||
<span className="font-satoshi text-black">Drills</span>
|
<span>Drills</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/student/practice/hard-test-modules"
|
to="/student/practice/hard-test-modules"
|
||||||
className="text-black text-sm flex items-center gap-3"
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? "bg-white text-slate-900"
|
||||||
|
: "text-slate-500 hover:bg-white hover:text-slate-900"
|
||||||
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trophy size={18} className="text-black" />
|
<Trophy size={18} className="text-slate-400" />
|
||||||
<span className="font-satoshi text-black">
|
<span>Hard Test Modules</span>
|
||||||
Hard Test Modules
|
|
||||||
</span>
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuSub>
|
</SidebarMenuSub>
|
||||||
)}
|
)}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
{/* DOCS */}
|
{/* LESSONS */}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
|
||||||
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/student/lessons`}
|
to="/student/lessons"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 text-sm font-satoshi ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-zinc-800 text-white"
|
? "text-slate-900"
|
||||||
: "text-zinc-400 hover:bg-zinc-800"
|
: "text-slate-500 group-hover:text-slate-900"
|
||||||
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SidebarMenuButton className="cursor-pointer">
|
{({ isActive }) => (
|
||||||
<Video size={18} className="text-black" />
|
<>
|
||||||
<span className="text-black font-satoshi">Lessons</span>
|
<Video
|
||||||
</SidebarMenuButton>
|
size={18}
|
||||||
|
className={
|
||||||
|
isActive ? "text-cyan-500" : "text-slate-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Lessons</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
{/* SETTINGS */}
|
{/* REWARDS */}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
|
||||||
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/student/rewards`}
|
to="/student/rewards"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 text-sm font-satoshi ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-zinc-800 text-white"
|
? "text-slate-900"
|
||||||
: "text-zinc-400 hover:bg-zinc-800"
|
: "text-slate-500 group-hover:text-slate-900"
|
||||||
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SidebarMenuButton className="cursor-pointer">
|
{({ isActive }) => (
|
||||||
<Trophy size={18} className="text-black" />
|
<>
|
||||||
<span className="text-black font-satoshi">Rewards</span>
|
<Trophy
|
||||||
</SidebarMenuButton>
|
size={18}
|
||||||
</NavLink>
|
className={
|
||||||
</SidebarMenuItem>
|
isActive ? "text-emerald-500" : "text-slate-400"
|
||||||
<SidebarMenuItem>
|
|
||||||
<NavLink
|
|
||||||
to={`/student/profile`}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
isActive
|
|
||||||
? "bg-zinc-800 text-white"
|
|
||||||
: "text-zinc-400 hover:bg-zinc-800"
|
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<SidebarMenuButton className="cursor-pointer">
|
<span>Rewards</span>
|
||||||
<User size={18} className="text-black" />
|
</>
|
||||||
<span className="text-black font-satoshi">Profile</span>
|
)}
|
||||||
</SidebarMenuButton>
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
{/* FOOTER */}
|
{/* FOOTER – links to profile */}
|
||||||
<SidebarFooter>
|
<SidebarFooter className="mt-auto px-3 pb-3 pt-4">
|
||||||
<div className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/student/profile")}
|
||||||
|
className="flex w-full items-center gap-3 rounded-2xl bg-white/60 px-3 py-2 text-left shadow-sm ring-1 ring-white/80 hover:bg-white"
|
||||||
|
>
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarImage src={user?.avatar_url} />
|
<AvatarImage src={user?.avatar_url} />
|
||||||
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-purple-400 to-purple-500 uppercase">
|
<AvatarFallback className="bg-linear-to-br from-purple-400 to-purple-500 font-satoshi-bold uppercase text-white">
|
||||||
{user?.name.slice(0, 1)}
|
{user?.name.slice(0, 1)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col text-sm">
|
<div className="flex flex-col text-sm">
|
||||||
<span className="font-medium text-black">{user?.name}</span>
|
<span className="font-medium text-slate-900">{user?.name}</span>
|
||||||
<span className="text-xs text-gray-400">{user?.email}</span>
|
<span className="text-xs text-slate-400">{user?.email}</span>
|
||||||
</div>
|
|
||||||
<ChevronDown size={16} className="ml-auto" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronDown size={16} className="ml-auto text-slate-400" />
|
||||||
|
</button>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
718
src/components/ChestOpenModal.tsx
Normal file
718
src/components/ChestOpenModal.tsx
Normal file
@ -0,0 +1,718 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import type { QuestNode } from "../types/quest";
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const S = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@700;900&family=Nunito:wght@800;900&display=swap');
|
||||||
|
|
||||||
|
/* ══ FULL SCREEN OVERLAY ══ */
|
||||||
|
.com-overlay {
|
||||||
|
position:fixed; inset:0; z-index:80;
|
||||||
|
display:flex; flex-direction:column;
|
||||||
|
align-items:center; justify-content:center;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sky/sea background that animates in ── */
|
||||||
|
.com-bg {
|
||||||
|
position:absolute; inset:0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 80%, rgba(0,60,120,0.9) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 50% 20%, rgba(80,0,160,0.7) 0%, transparent 60%),
|
||||||
|
linear-gradient(180deg, #050010 0%, #0a0520 40%, #020818 100%);
|
||||||
|
animation: comBgIn 0.5s ease both;
|
||||||
|
}
|
||||||
|
@keyframes comBgIn {
|
||||||
|
from{ opacity:0; } to{ opacity:1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stars in background ── */
|
||||||
|
.com-star {
|
||||||
|
position:absolute; border-radius:50%;
|
||||||
|
background:white; pointer-events:none;
|
||||||
|
animation:comStarTwinkle var(--sdur) ease-in-out infinite;
|
||||||
|
animation-delay:var(--sdelay);
|
||||||
|
}
|
||||||
|
@keyframes comStarTwinkle {
|
||||||
|
0%,100%{ opacity:0.3; transform:scale(1); }
|
||||||
|
50% { opacity:1; transform:scale(1.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gold radial burst (appears on open) ── */
|
||||||
|
.com-burst {
|
||||||
|
position:absolute; inset:0;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
pointer-events:none; z-index:2;
|
||||||
|
}
|
||||||
|
.com-burst-ring {
|
||||||
|
position:absolute; border-radius:50%;
|
||||||
|
border:3px solid rgba(251,191,36,0.6);
|
||||||
|
animation: comBurstRing var(--brdur) ease-out forwards;
|
||||||
|
animation-delay: var(--brdelay);
|
||||||
|
opacity:0;
|
||||||
|
}
|
||||||
|
@keyframes comBurstRing {
|
||||||
|
0% { opacity:0.9; transform:scale(0.1); }
|
||||||
|
100%{ opacity:0; transform:scale(var(--brs)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ray beams (crepuscular rays) ── */
|
||||||
|
.com-rays {
|
||||||
|
position:absolute; inset:0;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
pointer-events:none; z-index:1;
|
||||||
|
}
|
||||||
|
.com-ray {
|
||||||
|
position:absolute;
|
||||||
|
width:3px;
|
||||||
|
height:55vh;
|
||||||
|
border-radius:100px;
|
||||||
|
background:linear-gradient(180deg,rgba(251,191,36,0.5) 0%,transparent 100%);
|
||||||
|
transform-origin:50% 100%;
|
||||||
|
bottom:50%;
|
||||||
|
left:calc(50% - 1.5px);
|
||||||
|
transform:rotate(var(--angle)) scaleY(0);
|
||||||
|
animation:comRayIn 0.6s ease-out forwards;
|
||||||
|
animation-delay:var(--raydelay);
|
||||||
|
}
|
||||||
|
@keyframes comRayIn {
|
||||||
|
0% { transform:rotate(var(--angle)) scaleY(0); opacity:0; }
|
||||||
|
40% { opacity:0.8; }
|
||||||
|
100%{ transform:rotate(var(--angle)) scaleY(1); opacity:0.15; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Particle explosion ── */
|
||||||
|
.com-particle {
|
||||||
|
position:absolute; border-radius:50%;
|
||||||
|
pointer-events:none; z-index:4;
|
||||||
|
animation:comParticleOut var(--pdur) cubic-bezier(0.25,0.8,0.35,1) forwards;
|
||||||
|
animation-delay:var(--pdelay);
|
||||||
|
opacity:0;
|
||||||
|
}
|
||||||
|
@keyframes comParticleOut {
|
||||||
|
0% { opacity:1; transform:translate(0,0) scale(1) rotate(0deg); }
|
||||||
|
80% { opacity:0.7; }
|
||||||
|
100%{ opacity:0; transform:translate(var(--ptx),var(--pty)) scale(0.2) rotate(var(--prot)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Coin emojis bursting ── */
|
||||||
|
.com-coin {
|
||||||
|
position:absolute;
|
||||||
|
font-size:var(--csize);
|
||||||
|
pointer-events:none; z-index:4;
|
||||||
|
animation:comCoinOut var(--cdur) cubic-bezier(0.2,0.9,0.3,1) forwards;
|
||||||
|
animation-delay:var(--cdelay);
|
||||||
|
opacity:0;
|
||||||
|
}
|
||||||
|
@keyframes comCoinOut {
|
||||||
|
0% { opacity:0; transform:translate(0,0) rotate(0deg) scale(0.3); }
|
||||||
|
12% { opacity:1; }
|
||||||
|
100%{ opacity:0; transform:translate(var(--ctx),var(--cty)) rotate(var(--crot)) scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Floating sparkles (stay on screen) ── */
|
||||||
|
.com-sparkle {
|
||||||
|
position:absolute; pointer-events:none; z-index:3;
|
||||||
|
font-size:var(--spsize);
|
||||||
|
animation:comSparkleFloat var(--spdur) ease-in-out infinite;
|
||||||
|
animation-delay:var(--spdelay);
|
||||||
|
opacity:0.7;
|
||||||
|
}
|
||||||
|
@keyframes comSparkleFloat {
|
||||||
|
0%,100%{ transform:translateY(0) rotate(0deg) scale(1); opacity:0.6; }
|
||||||
|
50% { transform:translateY(-18px) rotate(180deg) scale(1.2); opacity:1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── XP number that flies up from chest ── */
|
||||||
|
.com-xp-blast {
|
||||||
|
position:absolute; pointer-events:none; z-index:5;
|
||||||
|
top:50%; left:50%;
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:2.6rem; font-weight:900;
|
||||||
|
color:#fbbf24;
|
||||||
|
text-shadow:0 0 30px rgba(251,191,36,1),0 0 60px rgba(251,191,36,0.7),0 0 100px rgba(251,191,36,0.3);
|
||||||
|
white-space:nowrap;
|
||||||
|
animation:comXPBlast 2s cubic-bezier(0.2,0.8,0.3,1) forwards;
|
||||||
|
}
|
||||||
|
@keyframes comXPBlast {
|
||||||
|
0% { opacity:0; transform:translate(-50%,-40%) scale(0.4); filter:blur(4px); }
|
||||||
|
15% { opacity:1; transform:translate(-50%,-60%) scale(1.3); filter:blur(0); }
|
||||||
|
60% { opacity:1; transform:translate(-50%,-90%) scale(1); }
|
||||||
|
100%{ opacity:0; transform:translate(-50%,-130%) scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main card ── */
|
||||||
|
.com-card {
|
||||||
|
position:relative; z-index:6;
|
||||||
|
width:calc(100% - 2.5rem); max-width:340px;
|
||||||
|
border-radius:28px; overflow:hidden;
|
||||||
|
display:flex; flex-direction:column; align-items:center;
|
||||||
|
padding:0;
|
||||||
|
box-shadow:0 0 80px rgba(251,191,36,0.2), 0 24px 64px rgba(0,0,0,0.7);
|
||||||
|
animation:comCardIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
animation-delay:0.1s;
|
||||||
|
}
|
||||||
|
@keyframes comCardIn {
|
||||||
|
from{ opacity:0; transform:scale(0.8) translateY(24px); }
|
||||||
|
to { opacity:1; transform:scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold shimmer top border */
|
||||||
|
.com-card::before {
|
||||||
|
content:''; position:absolute; top:0; left:0; right:0; height:2px; z-index:1;
|
||||||
|
background:linear-gradient(90deg,transparent 0%,#f59e0b 30%,#fbbf24 50%,#f59e0b 70%,transparent 100%);
|
||||||
|
background-size:200% 100%;
|
||||||
|
animation:comShimmer 2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes comShimmer {
|
||||||
|
0% { background-position:200% 0; }
|
||||||
|
100%{ background-position:-200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card inner bg */
|
||||||
|
.com-card-inner {
|
||||||
|
width:100%; padding:1.75rem 1.6rem 1.6rem;
|
||||||
|
background:linear-gradient(160deg,#12083a 0%,#0c0525 60%,#090320 100%);
|
||||||
|
border:1.5px solid rgba(251,191,36,0.25);
|
||||||
|
border-radius:28px;
|
||||||
|
display:flex; flex-direction:column; align-items:center; gap:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Phase label ── */
|
||||||
|
.com-label {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:0.62rem; font-weight:700; letter-spacing:0.2em;
|
||||||
|
text-transform:uppercase; color:rgba(251,191,36,0.55);
|
||||||
|
margin-bottom:1.2rem; text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chest area ── */
|
||||||
|
.com-chest-area {
|
||||||
|
position:relative; width:140px; height:140px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
margin-bottom:1.25rem; cursor:pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow platform beneath chest */
|
||||||
|
.com-glow-pad {
|
||||||
|
position:absolute; bottom:6px; left:50%;
|
||||||
|
transform:translateX(-50%);
|
||||||
|
width:100px; height:24px; border-radius:50%;
|
||||||
|
background:radial-gradient(ellipse at center,rgba(251,191,36,0.45) 0%,transparent 70%);
|
||||||
|
animation:comGlowPad 1.8s ease-in-out infinite;
|
||||||
|
filter:blur(4px);
|
||||||
|
}
|
||||||
|
@keyframes comGlowPad {
|
||||||
|
0%,100%{ transform:translateX(-50%) scaleX(1); opacity:0.7; }
|
||||||
|
50% { transform:translateX(-50%) scaleX(1.2); opacity:1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orbit ring */
|
||||||
|
.com-orbit {
|
||||||
|
position:absolute; inset:8px; border-radius:50%;
|
||||||
|
border:1.5px dashed rgba(251,191,36,0.2);
|
||||||
|
animation:comOrbit 8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes comOrbit { from{transform:rotate(0deg);} to{transform:rotate(360deg);} }
|
||||||
|
.com-orbit-dot {
|
||||||
|
position:absolute; top:-5px; left:50%; transform:translateX(-50%);
|
||||||
|
width:8px; height:8px; border-radius:50%;
|
||||||
|
background:#fbbf24; box-shadow:0 0 10px #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chest emoji */
|
||||||
|
.com-chest {
|
||||||
|
font-size:5.5rem; position:relative; z-index:2;
|
||||||
|
filter:drop-shadow(0 8px 20px rgba(251,191,36,0.45));
|
||||||
|
transition:filter 0.2s;
|
||||||
|
}
|
||||||
|
.com-chest.idle {
|
||||||
|
animation:comChestIdle 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes comChestIdle {
|
||||||
|
0%,100%{ transform:translateY(0) rotate(-2deg); }
|
||||||
|
50% { transform:translateY(-6px) rotate(2deg); }
|
||||||
|
}
|
||||||
|
.com-chest.shake {
|
||||||
|
animation:comChestShake 0.55s cubic-bezier(0.36,0.07,0.19,0.97) both;
|
||||||
|
}
|
||||||
|
@keyframes comChestShake {
|
||||||
|
0%,100%{ transform:rotate(0deg) scale(1); }
|
||||||
|
10% { transform:rotate(-14deg) scale(1.06); }
|
||||||
|
25% { transform:rotate(14deg) scale(1.1); }
|
||||||
|
40% { transform:rotate(-10deg) scale(1.07); }
|
||||||
|
55% { transform:rotate(10deg) scale(1.12); }
|
||||||
|
70% { transform:rotate(-6deg) scale(1.06); }
|
||||||
|
85% { transform:rotate(6deg) scale(1.04); }
|
||||||
|
}
|
||||||
|
.com-chest.opening {
|
||||||
|
animation:comChestOpen 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes comChestOpen {
|
||||||
|
0% { transform:scale(0.7); filter:brightness(0.4) drop-shadow(0 8px 20px rgba(251,191,36,0.3)); }
|
||||||
|
50% { transform:scale(1.25); filter:brightness(1.8) drop-shadow(0 0 50px rgba(251,191,36,1)); }
|
||||||
|
100%{ transform:scale(1); filter:brightness(1) drop-shadow(0 8px 30px rgba(251,191,36,0.6)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tap prompt ── */
|
||||||
|
.com-tap-title {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:1.2rem; font-weight:900; color:white;
|
||||||
|
text-align:center; margin-bottom:0.3rem;
|
||||||
|
animation:comPulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes comPulse {
|
||||||
|
0%,100%{ opacity:1; transform:scale(1); }
|
||||||
|
50% { opacity:0.65; transform:scale(0.97); }
|
||||||
|
}
|
||||||
|
.com-tap-sub {
|
||||||
|
font-family:'Nunito',sans-serif;
|
||||||
|
font-size:0.75rem; font-weight:800;
|
||||||
|
color:rgba(255,255,255,0.35); text-align:center; margin-bottom:1.5rem;
|
||||||
|
letter-spacing:0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shaking text ── */
|
||||||
|
.com-shake-text {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:1.1rem; font-weight:900; color:#fbbf24;
|
||||||
|
text-align:center; margin-bottom:0.3rem;
|
||||||
|
animation:comShakeText 0.3s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes comShakeText {
|
||||||
|
from{ transform:translateX(-3px); }
|
||||||
|
to { transform:translateX(3px); }
|
||||||
|
}
|
||||||
|
.com-shake-dots {
|
||||||
|
font-size:1.4rem; text-align:center; margin-bottom:1.5rem;
|
||||||
|
animation:comShakeText 0.25s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reward rows ── */
|
||||||
|
.com-rewards-title {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:0.65rem; font-weight:700; letter-spacing:0.18em;
|
||||||
|
text-transform:uppercase; color:rgba(251,191,36,0.5);
|
||||||
|
text-align:center; margin-bottom:0.85rem;
|
||||||
|
}
|
||||||
|
.com-rewards { display:flex; flex-direction:column; gap:0.55rem; width:100%; margin-bottom:1.1rem; }
|
||||||
|
.com-reward-row {
|
||||||
|
display:flex; align-items:center; gap:0.85rem;
|
||||||
|
padding:0.8rem 1rem;
|
||||||
|
background:rgba(255,255,255,0.04);
|
||||||
|
border:1px solid rgba(251,191,36,0.18);
|
||||||
|
border-radius:16px;
|
||||||
|
animation:comRowIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes comRowIn {
|
||||||
|
from{ opacity:0; transform:translateY(18px) scale(0.88); }
|
||||||
|
to { opacity:1; transform:translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
.com-reward-icon { font-size:1.5rem; flex-shrink:0; filter:drop-shadow(0 2px 8px rgba(251,191,36,0.5)); }
|
||||||
|
.com-reward-lbl {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:0.65rem; font-weight:700; letter-spacing:0.1em; text-transform:uppercase;
|
||||||
|
color:rgba(255,255,255,0.4); margin-bottom:0.12rem;
|
||||||
|
}
|
||||||
|
.com-reward-val {
|
||||||
|
font-family:'Nunito',sans-serif;
|
||||||
|
font-size:1.05rem; font-weight:900;
|
||||||
|
color:#fbbf24;
|
||||||
|
text-shadow:0 0 16px rgba(251,191,36,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special XP row highlight */
|
||||||
|
.com-reward-row.xp-row {
|
||||||
|
border-color:rgba(251,191,36,0.35);
|
||||||
|
background:rgba(251,191,36,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTA button ── */
|
||||||
|
.com-cta {
|
||||||
|
width:100%; padding:1rem;
|
||||||
|
background:linear-gradient(135deg,#fbbf24,#f59e0b);
|
||||||
|
border:none; border-radius:16px; cursor:pointer;
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:1rem; font-weight:900; color:#1a0800;
|
||||||
|
letter-spacing:0.05em;
|
||||||
|
box-shadow:0 5px 0 #b45309, 0 8px 24px rgba(251,191,36,0.4);
|
||||||
|
transition:all 0.12s ease;
|
||||||
|
animation:comRowIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
.com-cta:hover { transform:translateY(-3px); box-shadow:0 8px 0 #b45309, 0 14px 32px rgba(251,191,36,0.5); }
|
||||||
|
.com-cta:active { transform:translateY(2px); box-shadow:0 3px 0 #b45309; }
|
||||||
|
|
||||||
|
/* ── Skip hint ── */
|
||||||
|
.com-skip {
|
||||||
|
position:absolute; bottom:1.5rem;
|
||||||
|
font-family:'Nunito',sans-serif;
|
||||||
|
font-size:0.65rem; font-weight:700;
|
||||||
|
color:rgba(255,255,255,0.2); letter-spacing:0.1em;
|
||||||
|
text-transform:uppercase; cursor:pointer; z-index:7;
|
||||||
|
transition:color 0.2s;
|
||||||
|
}
|
||||||
|
.com-skip:hover { color:rgba(255,255,255,0.5); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
const PARTICLE_COLORS = [
|
||||||
|
"#fbbf24",
|
||||||
|
"#f59e0b",
|
||||||
|
"#ef4444",
|
||||||
|
"#ec4899",
|
||||||
|
"#a855f7",
|
||||||
|
"#6366f1",
|
||||||
|
"#22d3ee",
|
||||||
|
"#4ade80",
|
||||||
|
"#fb923c",
|
||||||
|
];
|
||||||
|
const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"];
|
||||||
|
const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"];
|
||||||
|
|
||||||
|
// Rays at evenly spaced angles
|
||||||
|
const RAYS = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
angle: `${(i / 12) * 360}deg`,
|
||||||
|
delay: `${i * 0.04}s`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Burst rings
|
||||||
|
const BURST_RINGS = [
|
||||||
|
{ id: 0, size: "3", dur: "0.7s", delay: "0s" },
|
||||||
|
{ id: 1, size: "5", dur: "0.9s", delay: "0.1s" },
|
||||||
|
{ id: 2, size: "8", dur: "1.1s", delay: "0.2s" },
|
||||||
|
{ id: 3, size: "12", dur: "1.4s", delay: "0.3s" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Stars in background — stable between renders
|
||||||
|
const STARS = Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
w: 1 + ((i * 7) % 3),
|
||||||
|
top: `${(i * 17 + 3) % 95}%`,
|
||||||
|
left: `${(i * 23 + 11) % 97}%`,
|
||||||
|
dur: `${2 + ((i * 3) % 4)}s`,
|
||||||
|
delay: `${(i * 7) % 3}s`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sparkles floating around the revealed card
|
||||||
|
const SPARKLES = Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
emoji: SPARKLE_EMOJIS[i % 4],
|
||||||
|
size: `${0.9 + (i % 3) * 0.35}rem`,
|
||||||
|
top: `${10 + ((i * 12) % 75)}%`,
|
||||||
|
left: `${5 + ((i * 14) % 85)}%`,
|
||||||
|
dur: `${2 + (i % 3) * 1.2}s`,
|
||||||
|
delay: `${i * 0.3}s`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Phase = "idle" | "shaking" | "opening" | "revealed";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: QuestNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChestOpenModal = ({ node, onClose }: Props) => {
|
||||||
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
|
const [showXP, setShowXP] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Stable particle arrays computed once per mount
|
||||||
|
const particles = useRef(
|
||||||
|
Array.from({ length: 55 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
color: PARTICLE_COLORS[i % PARTICLE_COLORS.length],
|
||||||
|
w: 3 + (i % 3) * 4,
|
||||||
|
tx: ((i % 2 === 0 ? 1 : -1) * (40 + i * 7)) % 200,
|
||||||
|
ty: -(30 + ((i * 11) % 190)),
|
||||||
|
rot: ((i * 23) % 720) - 360,
|
||||||
|
dur: `${0.7 + ((i * 7) % 10) / 10}s`,
|
||||||
|
delay: `${((i * 3) % 8) / 30}s`,
|
||||||
|
})),
|
||||||
|
).current;
|
||||||
|
|
||||||
|
const coins = useRef(
|
||||||
|
Array.from({ length: 18 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
emoji: COIN_EMOJIS[i % COIN_EMOJIS.length],
|
||||||
|
size: `${1 + (i % 3) * 0.45}rem`,
|
||||||
|
tx: (i % 2 === 0 ? 1 : -1) * (30 + ((i * 9) % 180)),
|
||||||
|
ty: -(40 + ((i * 13) % 200)),
|
||||||
|
rot: ((i * 31) % 540) - 270,
|
||||||
|
dur: `${0.75 + ((i * 7) % 8) / 10}s`,
|
||||||
|
delay: `${((i * 5) % 10) / 30}s`,
|
||||||
|
})),
|
||||||
|
).current;
|
||||||
|
|
||||||
|
const tap = () => {
|
||||||
|
if (phase !== "idle") return;
|
||||||
|
setPhase("shaking");
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setPhase("opening");
|
||||||
|
setShowXP(true);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setShowXP(false);
|
||||||
|
setPhase("revealed");
|
||||||
|
}, 1800);
|
||||||
|
}, 650);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rewards = [
|
||||||
|
{
|
||||||
|
key: "xp",
|
||||||
|
cls: "xp-row",
|
||||||
|
icon: "⚡",
|
||||||
|
lbl: "XP Gained",
|
||||||
|
val: `+${node.reward.xp} XP`,
|
||||||
|
delay: "0.05s",
|
||||||
|
},
|
||||||
|
...(node.reward.title
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
cls: "",
|
||||||
|
icon: "🏴☠️",
|
||||||
|
lbl: "Crew Title",
|
||||||
|
val: node.reward.title,
|
||||||
|
delay: "0.15s",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(node.reward.itemLabel
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "item",
|
||||||
|
cls: "",
|
||||||
|
icon: "🎁",
|
||||||
|
lbl: "Item",
|
||||||
|
val: node.reward.itemLabel,
|
||||||
|
delay: "0.25s",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const chestClass =
|
||||||
|
phase === "idle"
|
||||||
|
? "idle"
|
||||||
|
: phase === "shaking"
|
||||||
|
? "shake"
|
||||||
|
: phase === "opening"
|
||||||
|
? "opening"
|
||||||
|
: "";
|
||||||
|
const chestEmoji = phase === "opening" || phase === "revealed" ? "📬" : "📦";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="com-overlay" onClick={phase === "idle" ? tap : undefined}>
|
||||||
|
<style>{S}</style>
|
||||||
|
|
||||||
|
{/* Background */}
|
||||||
|
<div className="com-bg" />
|
||||||
|
|
||||||
|
{/* Stars */}
|
||||||
|
{STARS.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="com-star"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: s.w,
|
||||||
|
height: s.w,
|
||||||
|
top: s.top,
|
||||||
|
left: s.left,
|
||||||
|
"--sdur": s.dur,
|
||||||
|
"--sdelay": s.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Crepuscular rays (appear on open) */}
|
||||||
|
{(phase === "opening" || phase === "revealed") && (
|
||||||
|
<div className="com-rays">
|
||||||
|
{RAYS.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="com-ray"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--angle": r.angle,
|
||||||
|
"--raydelay": r.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Burst rings */}
|
||||||
|
{(phase === "opening" || phase === "revealed") && (
|
||||||
|
<div className="com-burst">
|
||||||
|
{BURST_RINGS.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="com-burst-ring"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: "100px",
|
||||||
|
height: "100px",
|
||||||
|
"--brs": r.size,
|
||||||
|
"--brdur": r.dur,
|
||||||
|
"--brdelay": r.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Particle explosion */}
|
||||||
|
{(phase === "opening" || phase === "revealed") && (
|
||||||
|
<>
|
||||||
|
{particles.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="com-particle"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: p.w,
|
||||||
|
height: p.w,
|
||||||
|
background: p.color,
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
"--ptx": `${p.tx}px`,
|
||||||
|
"--pty": `${p.ty}px`,
|
||||||
|
"--prot": `${p.rot}deg`,
|
||||||
|
"--pdur": p.dur,
|
||||||
|
"--pdelay": p.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{coins.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="com-coin"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
"--csize": c.size,
|
||||||
|
"--ctx": `${c.tx}px`,
|
||||||
|
"--cty": `${c.ty}px`,
|
||||||
|
"--crot": `${c.rot}deg`,
|
||||||
|
"--cdur": c.dur,
|
||||||
|
"--cdelay": c.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{c.emoji}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Floating sparkles in revealed state */}
|
||||||
|
{phase === "revealed" &&
|
||||||
|
SPARKLES.map((sp) => (
|
||||||
|
<div
|
||||||
|
key={sp.id}
|
||||||
|
className="com-sparkle"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
top: sp.top,
|
||||||
|
left: sp.left,
|
||||||
|
"--spsize": sp.size,
|
||||||
|
"--spdur": sp.dur,
|
||||||
|
"--spdelay": sp.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sp.emoji}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* XP blast */}
|
||||||
|
{showXP && <div className="com-xp-blast">+{node.reward.xp} XP</div>}
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="com-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="com-card-inner">
|
||||||
|
<p className="com-label">
|
||||||
|
{phase === "revealed" ? "⚓ Treasure Claimed" : "📦 Treasure Chest"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Chest */}
|
||||||
|
<div
|
||||||
|
className="com-chest-area"
|
||||||
|
onClick={phase === "idle" ? tap : undefined}
|
||||||
|
style={{ cursor: phase === "idle" ? "pointer" : "default" }}
|
||||||
|
>
|
||||||
|
{phase !== "revealed" && <div className="com-glow-pad" />}
|
||||||
|
{phase !== "revealed" && (
|
||||||
|
<div className="com-orbit">
|
||||||
|
<div className="com-orbit-dot" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className={`com-chest ${chestClass}`}>{chestEmoji}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase content */}
|
||||||
|
{phase === "idle" && (
|
||||||
|
<>
|
||||||
|
<p className="com-tap-title">Tap to Open!</p>
|
||||||
|
<p className="com-tap-sub">YOUR HARD WORK HAS PAID OFF, PIRATE</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "shaking" && (
|
||||||
|
<>
|
||||||
|
<p className="com-shake-text">The chest stirs...</p>
|
||||||
|
<p className="com-shake-dots">⚡ ⚡ ⚡</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "revealed" && (
|
||||||
|
<>
|
||||||
|
<p className="com-rewards-title">⚓ Spoils of Victory</p>
|
||||||
|
<div className="com-rewards">
|
||||||
|
{rewards.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.key}
|
||||||
|
className={`com-reward-row ${r.cls}`}
|
||||||
|
style={{ animationDelay: r.delay }}
|
||||||
|
>
|
||||||
|
<span className="com-reward-icon">{r.icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="com-reward-lbl">{r.lbl}</p>
|
||||||
|
<p className="com-reward-val">{r.val}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="com-cta"
|
||||||
|
style={{ animationDelay: rewards.length * 0.1 + "s" }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
⚓ Set Sail
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip link for impatient pirates */}
|
||||||
|
{phase === "revealed" && (
|
||||||
|
<p className="com-skip" onClick={onClose}>
|
||||||
|
tap anywhere to continue
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,126 @@
|
|||||||
import { Badge } from "./ui/badge";
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@600;700&display=swap');
|
||||||
|
|
||||||
|
.cc-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn:hover:not(.cc-selected) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.07);
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected state */
|
||||||
|
.cc-btn.cc-selected {
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
background: #fdf4ff;
|
||||||
|
box-shadow: 0 6px 0 #e9d5ff, 0 8px 20px rgba(168,85,247,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected shimmer bar on left edge */
|
||||||
|
.cc-btn.cc-selected::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: linear-gradient(180deg, #a855f7, #7c3aed);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top row */
|
||||||
|
.cc-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #1e1b4b;
|
||||||
|
line-height: 1.2;
|
||||||
|
flex: 1;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.cc-btn.cc-selected .cc-label { color: #7c3aed; }
|
||||||
|
|
||||||
|
/* Section badge */
|
||||||
|
.cc-section-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.cc-section-badge.ebrw {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.cc-section-badge.math {
|
||||||
|
background: #fff1f2;
|
||||||
|
border-color: #fecdd3;
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sub label */
|
||||||
|
.cc-sublabel {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.3;
|
||||||
|
padding-left: 0.05rem;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.cc-btn.cc-selected .cc-sublabel { color: #a855f7; }
|
||||||
|
|
||||||
|
/* Checkmark */
|
||||||
|
.cc-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.65rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.cc-btn.cc-selected .cc-check {
|
||||||
|
background: #a855f7;
|
||||||
|
border-color: #a855f7;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let stylesInjected = false;
|
||||||
|
|
||||||
export const ChoiceCard = ({
|
export const ChoiceCard = ({
|
||||||
label,
|
label,
|
||||||
@ -12,23 +134,51 @@ export const ChoiceCard = ({
|
|||||||
subLabel?: string;
|
subLabel?: string;
|
||||||
section?: string;
|
section?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) => (
|
}) => {
|
||||||
|
if (!stylesInjected) {
|
||||||
|
const tag = document.createElement("style");
|
||||||
|
tag.textContent = STYLES;
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
stylesInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionClass =
|
||||||
|
section === "EBRW"
|
||||||
|
? "ebrw"
|
||||||
|
: section === "Math" || section === "MATH"
|
||||||
|
? "math"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`rounded-2xl border p-4 text-left transition flex flex-col
|
className={`cc-btn${selected ? " cc-selected" : ""}`}
|
||||||
${selected ? "border-indigo-600 bg-indigo-50" : "hover:border-gray-300"}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between">
|
{/* Checkmark */}
|
||||||
<span className="font-satoshi-bold text-lg">{label}</span>
|
<div className="cc-check">
|
||||||
{section && (
|
{selected && (
|
||||||
<Badge
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||||
variant={"secondary"}
|
<path
|
||||||
className={`font-satoshi text-sm ${section === "EBRW" ? "bg-blue-400 text-blue-100" : "bg-red-400 text-red-100"}`}
|
d="M1.5 5L4 7.5L8.5 2.5"
|
||||||
>
|
stroke="white"
|
||||||
{section}
|
strokeWidth="1.8"
|
||||||
</Badge>
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{subLabel && <span className="font-satoshi text-md">{subLabel}</span>}
|
|
||||||
|
{/* Top row: label + section badge */}
|
||||||
|
<div className="cc-top" style={{ paddingRight: "1.75rem" }}>
|
||||||
|
<span className="cc-label">{label}</span>
|
||||||
|
{section && (
|
||||||
|
<span className={`cc-section-badge ${sectionClass}`}>{section}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub label */}
|
||||||
|
{subLabel && <span className="cc-sublabel">{subLabel}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -11,26 +11,187 @@ type Props = {
|
|||||||
level: number;
|
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 = ({
|
export const CircularLevelProgress = ({
|
||||||
size = 300,
|
|
||||||
strokeWidth = 16,
|
|
||||||
previousXP,
|
previousXP,
|
||||||
gainedXP,
|
gainedXP,
|
||||||
levelMinXP,
|
levelMinXP,
|
||||||
levelMaxXP,
|
levelMaxXP,
|
||||||
level,
|
level,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const radius = (size - strokeWidth) / 2;
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const levelRange = levelMaxXP - levelMinXP;
|
const levelRange = levelMaxXP - levelMinXP;
|
||||||
|
|
||||||
const normalize = (xp: number) =>
|
const normalize = (xp: number) =>
|
||||||
Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange;
|
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 [currentLevel, setCurrentLevel] = useState(level);
|
||||||
const [showLevelUp, setShowLevelUp] = useState(false);
|
const [showLevelUp, setShowLevelUp] = useState(false);
|
||||||
const [showThresholdText, setShowThresholdText] = useState(false);
|
const [showXPTotal, setShowXPTotal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let animationFrame: number;
|
let animationFrame: number;
|
||||||
@ -38,28 +199,23 @@ export const CircularLevelProgress = ({
|
|||||||
|
|
||||||
const availableXP = previousXP + gainedXP;
|
const availableXP = previousXP + gainedXP;
|
||||||
const crossesLevel = availableXP >= levelMaxXP;
|
const crossesLevel = availableXP >= levelMaxXP;
|
||||||
|
const phase1Target = crossesLevel ? 1 : normalize(availableXP);
|
||||||
const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP);
|
|
||||||
|
|
||||||
const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0;
|
const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0;
|
||||||
|
|
||||||
const duration = 1200;
|
const duration = 1200;
|
||||||
|
|
||||||
const animatePhase1 = (timestamp: number) => {
|
const animatePhase1 = (timestamp: number) => {
|
||||||
if (!start) start = timestamp;
|
if (!start) start = timestamp;
|
||||||
const t = Math.min((timestamp - start) / duration, 1);
|
const t = Math.min((timestamp - start) / duration, 1);
|
||||||
|
setBarProgress(
|
||||||
setProgress(
|
|
||||||
normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
|
normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (t < 1) {
|
if (t < 1) {
|
||||||
animationFrame = requestAnimationFrame(animatePhase1);
|
animationFrame = requestAnimationFrame(animatePhase1);
|
||||||
} else if (crossesLevel) {
|
} else if (crossesLevel) {
|
||||||
setShowLevelUp(true);
|
setShowLevelUp(true);
|
||||||
setTimeout(startPhase2, 1200);
|
setTimeout(startPhase2, 1200);
|
||||||
} else {
|
} else {
|
||||||
setShowThresholdText(true);
|
setShowXPTotal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,78 +223,77 @@ export const CircularLevelProgress = ({
|
|||||||
start = null;
|
start = null;
|
||||||
setShowLevelUp(false);
|
setShowLevelUp(false);
|
||||||
setCurrentLevel((l) => l + 1);
|
setCurrentLevel((l) => l + 1);
|
||||||
setProgress(0);
|
setBarProgress(0);
|
||||||
|
|
||||||
const target = Math.min(leftoverXP / levelRange, 1);
|
const target = Math.min(leftoverXP / levelRange, 1);
|
||||||
|
|
||||||
const animatePhase2 = (timestamp: number) => {
|
const animatePhase2 = (timestamp: number) => {
|
||||||
if (!start) start = timestamp;
|
if (!start) start = timestamp;
|
||||||
const t = Math.min((timestamp - start) / duration, 1);
|
const t = Math.min((timestamp - start) / duration, 1);
|
||||||
|
setBarProgress(t * target);
|
||||||
setProgress(t * target);
|
|
||||||
|
|
||||||
if (t < 1) {
|
if (t < 1) {
|
||||||
animationFrame = requestAnimationFrame(animatePhase2);
|
animationFrame = requestAnimationFrame(animatePhase2);
|
||||||
} else {
|
} else {
|
||||||
setShowThresholdText(true);
|
setShowXPTotal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
animationFrame = requestAnimationFrame(animatePhase2);
|
animationFrame = requestAnimationFrame(animatePhase2);
|
||||||
};
|
};
|
||||||
|
|
||||||
animationFrame = requestAnimationFrame(animatePhase1);
|
animationFrame = requestAnimationFrame(animatePhase1);
|
||||||
|
|
||||||
return () => cancelAnimationFrame(animationFrame);
|
return () => cancelAnimationFrame(animationFrame);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const offset = circumference * (1 - progress);
|
const barPct = Math.round(barProgress * 100);
|
||||||
|
const totalXP = previousXP + gainedXP;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col items-center gap-2">
|
<div className="clp-wrap">
|
||||||
|
<style>{STYLES}</style>
|
||||||
{showLevelUp && <ConfettiBurst />}
|
{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">
|
<div className="clp-card">
|
||||||
{currentLevel}
|
{/* 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 && (
|
<div className="clp-xp-chip">⚡ +{gainedXP} XP</div>
|
||||||
<span className="text-xl font-satoshi-medium text-gray-500 animate-fade-in">
|
</div>
|
||||||
Total XP: {previousXP + gainedXP}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* 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 && (
|
{showLevelUp && (
|
||||||
<span className="text-xl font-satoshi-medium text-purple-600 animate-fade-in">
|
<div className="clp-levelup">
|
||||||
🎉 You leveled up!
|
🎉 You leveled up! Welcome to Level {currentLevel}!
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,93 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
GGBApplet: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphProps {
|
|
||||||
width?: string;
|
|
||||||
height?: string;
|
|
||||||
commands?: string[];
|
|
||||||
defaultZoom?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Graph({
|
|
||||||
width = "w-full",
|
|
||||||
height = "h-30",
|
|
||||||
commands = [],
|
|
||||||
defaultZoom = 1,
|
|
||||||
}: GraphProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const appRef = useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!(window as any).GGBApplet) {
|
|
||||||
console.error("GeoGebra library not loaded");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const applet = new window.GGBApplet(
|
|
||||||
{
|
|
||||||
appName: "graphing",
|
|
||||||
width: 480,
|
|
||||||
height: 320,
|
|
||||||
scale: 1.4,
|
|
||||||
|
|
||||||
showToolBar: false,
|
|
||||||
showAlgebraInput: false,
|
|
||||||
showMenuBar: false,
|
|
||||||
showResetIcon: false,
|
|
||||||
|
|
||||||
enableRightClick: false,
|
|
||||||
enableLabelDrags: false,
|
|
||||||
enableShiftDragZoom: true,
|
|
||||||
showZoomButtons: true,
|
|
||||||
|
|
||||||
appletOnLoad(api: any) {
|
|
||||||
appRef.current = api;
|
|
||||||
|
|
||||||
api.setPerspective("G");
|
|
||||||
api.setMode(0);
|
|
||||||
api.setAxesVisible(true, true);
|
|
||||||
api.setGridVisible(true);
|
|
||||||
|
|
||||||
api.setCoordSystem(-5, 5, -5, 5);
|
|
||||||
|
|
||||||
commands.forEach((command, i) => {
|
|
||||||
const name = `f${i}`;
|
|
||||||
api.evalCommand(`${name}: ${command}`);
|
|
||||||
api.setFixed(name, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inside appletOnLoad:
|
|
||||||
},
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
applet.inject("ggb-container");
|
|
||||||
}, [commands, defaultZoom]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const resize = () => {
|
|
||||||
if (!containerRef.current || !appRef.current) return;
|
|
||||||
appRef.current.setSize(
|
|
||||||
containerRef.current.offsetWidth,
|
|
||||||
containerRef.current.offsetHeight,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", resize);
|
|
||||||
resize(); // initial resize
|
|
||||||
|
|
||||||
return () => window.removeEventListener("resize", resize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="h-[480] w-[320]">
|
|
||||||
<div id="ggb-container" className="w-full h-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,634 +0,0 @@
|
|||||||
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface Equation {
|
|
||||||
/** A JS math expression in terms of x. e.g. "Math.sin(x)", "x**2 - 3" */
|
|
||||||
fn: string;
|
|
||||||
/** Hex or CSS color string */
|
|
||||||
color?: string;
|
|
||||||
/** Display label e.g. "y = x²" */
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphPlotterProps {
|
|
||||||
equations: Equation[];
|
|
||||||
width?: number | string;
|
|
||||||
height?: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Intersection {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
eqA: number;
|
|
||||||
eqB: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipState {
|
|
||||||
screenX: number;
|
|
||||||
screenY: number;
|
|
||||||
mathX: number;
|
|
||||||
mathY: number;
|
|
||||||
eqA: number;
|
|
||||||
eqB: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Palette ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_COLORS = [
|
|
||||||
"#e05263", // crimson-rose
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#a855f7", // violet
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#f97316", // orange
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Safe function evaluator ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const buildFn = (expr: string): ((x: number) => number) => {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line no-new-func
|
|
||||||
return new Function(
|
|
||||||
"x",
|
|
||||||
`"use strict"; try { return ${expr}; } catch(e) { return NaN; }`,
|
|
||||||
) as (x: number) => number;
|
|
||||||
} catch {
|
|
||||||
return () => NaN;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Intersection finder (bisection on sign changes) ─────────────────────────
|
|
||||||
|
|
||||||
const findIntersections = (
|
|
||||||
fns: Array<(x: number) => number>,
|
|
||||||
xMin: number,
|
|
||||||
xMax: number,
|
|
||||||
steps = 800,
|
|
||||||
): Intersection[] => {
|
|
||||||
const results: Intersection[] = [];
|
|
||||||
const dx = (xMax - xMin) / steps;
|
|
||||||
|
|
||||||
for (let a = 0; a < fns.length; a++) {
|
|
||||||
for (let b = a + 1; b < fns.length; b++) {
|
|
||||||
const diff = (x: number) => fns[a](x) - fns[b](x);
|
|
||||||
let prev = diff(xMin);
|
|
||||||
|
|
||||||
for (let i = 1; i <= steps; i++) {
|
|
||||||
const x1 = xMin + i * dx;
|
|
||||||
const cur = diff(x1);
|
|
||||||
|
|
||||||
if (isFinite(prev) && isFinite(cur) && prev * cur < 0) {
|
|
||||||
// Bisect
|
|
||||||
let lo = x1 - dx,
|
|
||||||
hi = x1;
|
|
||||||
for (let k = 0; k < 42; k++) {
|
|
||||||
const mid = (lo + hi) / 2;
|
|
||||||
const m = diff(mid);
|
|
||||||
if (Math.abs(m) < 1e-10) {
|
|
||||||
lo = hi = mid;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (m * diff(lo) < 0) hi = mid;
|
|
||||||
else lo = mid;
|
|
||||||
}
|
|
||||||
const rx = (lo + hi) / 2;
|
|
||||||
const ry = fns[a](rx);
|
|
||||||
if (isFinite(rx) && isFinite(ry)) {
|
|
||||||
// Dedupe
|
|
||||||
const dupe = results.some(
|
|
||||||
(p) => p.eqA === a && p.eqB === b && Math.abs(p.x - rx) < 1e-4,
|
|
||||||
);
|
|
||||||
if (!dupe) results.push({ x: rx, y: ry, eqA: a, eqB: b });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev = cur;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const GraphPlotter = ({
|
|
||||||
equations,
|
|
||||||
width = "100%",
|
|
||||||
height = 480,
|
|
||||||
}: GraphPlotterProps) => {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Viewport state: origin in math coords + pixels-per-unit
|
|
||||||
const [viewport, setViewport] = useState({ cx: 0, cy: 0, scale: 60 });
|
|
||||||
const [canvasSize, setCanvasSize] = useState({ w: 600, h: 480 });
|
|
||||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
|
||||||
const [activeIntersections, setActiveIntersections] = useState<
|
|
||||||
Intersection[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
// Pan state
|
|
||||||
const isPanning = useRef(false);
|
|
||||||
const lastPointer = useRef({ x: 0, y: 0 });
|
|
||||||
const lastPinchDist = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// Build compiled functions
|
|
||||||
const compiledFns = useMemo(
|
|
||||||
() => equations.map((eq) => buildFn(eq.fn)),
|
|
||||||
[equations],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Math → screen
|
|
||||||
const toScreen = useCallback(
|
|
||||||
(mx: number, my: number, vp = viewport, cs = canvasSize) => ({
|
|
||||||
sx: cs.w / 2 + (mx - vp.cx) * vp.scale,
|
|
||||||
sy: cs.h / 2 - (my - vp.cy) * vp.scale,
|
|
||||||
}),
|
|
||||||
[viewport, canvasSize],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Screen → math
|
|
||||||
const toMath = useCallback(
|
|
||||||
(sx: number, sy: number, vp = viewport, cs = canvasSize) => ({
|
|
||||||
mx: vp.cx + (sx - cs.w / 2) / vp.scale,
|
|
||||||
my: vp.cy - (sy - cs.h / 2) / vp.scale,
|
|
||||||
}),
|
|
||||||
[viewport, canvasSize],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resize observer
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const ro = new ResizeObserver((entries) => {
|
|
||||||
for (const e of entries) {
|
|
||||||
const { width: w, height: h } = e.contentRect;
|
|
||||||
setCanvasSize({ w: Math.floor(w), h: Math.floor(h) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Compute intersections when fns or viewport changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (compiledFns.length < 2) {
|
|
||||||
setActiveIntersections([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const xMin = viewport.cx - canvasSize.w / (2 * viewport.scale);
|
|
||||||
const xMax = viewport.cx + canvasSize.w / (2 * viewport.scale);
|
|
||||||
const its = findIntersections(compiledFns, xMin, xMax);
|
|
||||||
setActiveIntersections(its);
|
|
||||||
}, [compiledFns, viewport, canvasSize]);
|
|
||||||
|
|
||||||
// ── Draw ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const { w, h } = canvasSize;
|
|
||||||
canvas.width = w * devicePixelRatio;
|
|
||||||
canvas.height = h * devicePixelRatio;
|
|
||||||
canvas.style.width = `${w}px`;
|
|
||||||
canvas.style.height = `${h}px`;
|
|
||||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
|
||||||
|
|
||||||
const vp = viewport;
|
|
||||||
const { sx: ox, sy: oy } = toScreen(0, 0, vp, canvasSize);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
ctx.fillStyle = "#fafaf9";
|
|
||||||
ctx.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Grid
|
|
||||||
const drawGrid = (
|
|
||||||
unit: number,
|
|
||||||
alpha: number,
|
|
||||||
lineWidth: number,
|
|
||||||
textSize?: number,
|
|
||||||
) => {
|
|
||||||
ctx.strokeStyle = `rgba(180,180,175,${alpha})`;
|
|
||||||
ctx.lineWidth = lineWidth;
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
const xStart = Math.floor((0 - ox) / (unit * vp.scale)) - 1;
|
|
||||||
const xEnd = Math.ceil((w - ox) / (unit * vp.scale)) + 1;
|
|
||||||
for (let i = xStart; i <= xEnd; i++) {
|
|
||||||
const sx = ox + i * unit * vp.scale;
|
|
||||||
ctx.moveTo(sx, 0);
|
|
||||||
ctx.lineTo(sx, h);
|
|
||||||
}
|
|
||||||
|
|
||||||
const yStart = Math.floor((oy - h) / (unit * vp.scale)) - 1;
|
|
||||||
const yEnd = Math.ceil(oy / (unit * vp.scale)) + 1;
|
|
||||||
for (let j = yStart; j <= yEnd; j++) {
|
|
||||||
const sy = oy - j * unit * vp.scale;
|
|
||||||
ctx.moveTo(0, sy);
|
|
||||||
ctx.lineTo(w, sy);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Labels
|
|
||||||
if (textSize) {
|
|
||||||
ctx.fillStyle = "#a8a29e";
|
|
||||||
ctx.font = `${textSize}px ui-monospace, monospace`;
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "top";
|
|
||||||
for (let i = xStart; i <= xEnd; i++) {
|
|
||||||
if (i === 0) continue;
|
|
||||||
const sx = ox + i * unit * vp.scale;
|
|
||||||
const val = i * unit;
|
|
||||||
const label = Number.isInteger(val) ? val.toString() : val.toFixed(1);
|
|
||||||
ctx.fillText(label, sx, oy + 4);
|
|
||||||
}
|
|
||||||
ctx.textAlign = "right";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
for (let j = yStart; j <= yEnd; j++) {
|
|
||||||
if (j === 0) continue;
|
|
||||||
const sy = oy - j * unit * vp.scale;
|
|
||||||
const val = j * unit;
|
|
||||||
const label = Number.isInteger(val) ? val.toString() : val.toFixed(1);
|
|
||||||
ctx.fillText(label, ox - 6, sy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Adaptive grid unit
|
|
||||||
const rawUnit = 1;
|
|
||||||
const targetPixels = 50;
|
|
||||||
const exp = Math.floor(Math.log10(targetPixels / vp.scale));
|
|
||||||
const unit = rawUnit * Math.pow(10, exp);
|
|
||||||
const subUnit = unit / 5;
|
|
||||||
|
|
||||||
drawGrid(subUnit, 0.35, 0.5);
|
|
||||||
drawGrid(unit, 0.7, 0.8, 10);
|
|
||||||
|
|
||||||
// Axes
|
|
||||||
ctx.strokeStyle = "#57534e";
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, oy);
|
|
||||||
ctx.lineTo(w, oy);
|
|
||||||
ctx.moveTo(ox, 0);
|
|
||||||
ctx.lineTo(ox, h);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Arrow heads
|
|
||||||
const arrow = (x: number, y: number, dir: "r" | "u") => {
|
|
||||||
ctx.fillStyle = "#57534e";
|
|
||||||
ctx.beginPath();
|
|
||||||
if (dir === "r") {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
ctx.lineTo(x - 8, y - 4);
|
|
||||||
ctx.lineTo(x - 8, y + 4);
|
|
||||||
} else {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
ctx.lineTo(x - 4, y + 8);
|
|
||||||
ctx.lineTo(x + 4, y + 8);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
};
|
|
||||||
arrow(w, oy, "r");
|
|
||||||
arrow(ox, 0, "u");
|
|
||||||
|
|
||||||
// Origin label
|
|
||||||
ctx.fillStyle = "#a8a29e";
|
|
||||||
ctx.font = "10px ui-monospace, monospace";
|
|
||||||
ctx.textAlign = "right";
|
|
||||||
ctx.textBaseline = "top";
|
|
||||||
ctx.fillText("0", ox - 5, oy + 4);
|
|
||||||
|
|
||||||
// ── Plot each equation ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const xMin = vp.cx - w / (2 * vp.scale);
|
|
||||||
const xMax = vp.cx + w / (2 * vp.scale);
|
|
||||||
const steps = w * 2;
|
|
||||||
const dx = (xMax - xMin) / steps;
|
|
||||||
|
|
||||||
equations.forEach((eq, idx) => {
|
|
||||||
const fn = compiledFns[idx];
|
|
||||||
const color = eq.color ?? DEFAULT_COLORS[idx % DEFAULT_COLORS.length];
|
|
||||||
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = 2.5;
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
let penDown = false;
|
|
||||||
let prevY = NaN;
|
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const mx = xMin + i * dx;
|
|
||||||
const my = fn(mx);
|
|
||||||
|
|
||||||
if (!isFinite(my)) {
|
|
||||||
penDown = false;
|
|
||||||
prevY = NaN;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Break line on discontinuities (asymptotes)
|
|
||||||
if (Math.abs(my - prevY) > (h / vp.scale) * 2) {
|
|
||||||
penDown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sx, sy } = toScreen(mx, my, vp, canvasSize);
|
|
||||||
if (!penDown) {
|
|
||||||
ctx.moveTo(sx, sy);
|
|
||||||
penDown = true;
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(sx, sy);
|
|
||||||
}
|
|
||||||
prevY = my;
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Intersection dots ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
activeIntersections.forEach((pt) => {
|
|
||||||
const { sx, sy } = toScreen(pt.x, pt.y, vp, canvasSize);
|
|
||||||
if (sx < 0 || sx > w || sy < 0 || sy > h) return;
|
|
||||||
|
|
||||||
// Outer glow ring
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, sy, 9, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.8)";
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Dot
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, sy, 5, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = "#1c1917";
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, sy, 3, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = "#fafaf9";
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
viewport,
|
|
||||||
canvasSize,
|
|
||||||
equations,
|
|
||||||
compiledFns,
|
|
||||||
activeIntersections,
|
|
||||||
toScreen,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Event handlers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const zoom = useCallback(
|
|
||||||
(factor: number, pivotSx: number, pivotSy: number) => {
|
|
||||||
setViewport((vp) => {
|
|
||||||
const { mx, my } = toMath(pivotSx, pivotSy, vp, canvasSize);
|
|
||||||
const newScale = Math.max(5, Math.min(2000, vp.scale * factor));
|
|
||||||
return {
|
|
||||||
scale: newScale,
|
|
||||||
cx: mx - (pivotSx - canvasSize.w / 2) / newScale,
|
|
||||||
cy: my + (pivotSy - canvasSize.h / 2) / newScale,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[toMath, canvasSize],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onWheel = useCallback(
|
|
||||||
(e: React.WheelEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const rect = canvasRef.current!.getBoundingClientRect();
|
|
||||||
const sx = e.clientX - rect.left;
|
|
||||||
const sy = e.clientY - rect.top;
|
|
||||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
|
||||||
zoom(factor, sx, sy);
|
|
||||||
},
|
|
||||||
[zoom],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
|
||||||
isPanning.current = true;
|
|
||||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
|
||||||
if (!isPanning.current) return;
|
|
||||||
const dx = e.clientX - lastPointer.current.x;
|
|
||||||
const dy = e.clientY - lastPointer.current.y;
|
|
||||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
|
||||||
setViewport((vp) => ({
|
|
||||||
...vp,
|
|
||||||
cx: vp.cx - dx / vp.scale,
|
|
||||||
cy: vp.cy + dy / vp.scale,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
|
||||||
isPanning.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Touch pinch-to-zoom
|
|
||||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
|
||||||
if (e.touches.length === 2) {
|
|
||||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
||||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
||||||
lastPinchDist.current = Math.hypot(dx, dy);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onTouchMove = useCallback(
|
|
||||||
(e: React.TouchEvent) => {
|
|
||||||
if (e.touches.length === 2 && lastPinchDist.current !== null) {
|
|
||||||
e.preventDefault();
|
|
||||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
||||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
||||||
const dist = Math.hypot(dx, dy);
|
|
||||||
const factor = dist / lastPinchDist.current;
|
|
||||||
lastPinchDist.current = dist;
|
|
||||||
|
|
||||||
const rect = canvasRef.current!.getBoundingClientRect();
|
|
||||||
const pivotX =
|
|
||||||
(e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
|
||||||
const pivotY =
|
|
||||||
(e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
|
||||||
zoom(factor, pivotX, pivotY);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[zoom],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tap to find nearest intersection
|
|
||||||
const onCanvasClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
const rect = canvasRef.current!.getBoundingClientRect();
|
|
||||||
const sx = e.clientX - rect.left;
|
|
||||||
const sy = e.clientY - rect.top;
|
|
||||||
const { mx, my } = toMath(sx, sy, viewport, canvasSize);
|
|
||||||
|
|
||||||
// Find closest intersection within 20px
|
|
||||||
let best: Intersection | null = null;
|
|
||||||
let bestDist = Infinity;
|
|
||||||
|
|
||||||
for (const pt of activeIntersections) {
|
|
||||||
const { sx: px, sy: py } = toScreen(pt.x, pt.y, viewport, canvasSize);
|
|
||||||
const d = Math.hypot(px - sx, py - sy);
|
|
||||||
if (d < 24 && d < bestDist) {
|
|
||||||
best = pt;
|
|
||||||
bestDist = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (best) {
|
|
||||||
const { sx: px, sy: py } = toScreen(
|
|
||||||
best.x,
|
|
||||||
best.y,
|
|
||||||
viewport,
|
|
||||||
canvasSize,
|
|
||||||
);
|
|
||||||
setTooltip({
|
|
||||||
screenX: px,
|
|
||||||
screenY: py,
|
|
||||||
mathX: best.x,
|
|
||||||
mathY: best.y,
|
|
||||||
eqA: best.eqA,
|
|
||||||
eqB: best.eqB,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTooltip(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[activeIntersections, toMath, toScreen, viewport, canvasSize],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fmt = (n: number) => {
|
|
||||||
if (Math.abs(n) < 1e-9) return "0";
|
|
||||||
if (Math.abs(n) >= 1e4 || (Math.abs(n) < 1e-3 && n !== 0))
|
|
||||||
return n.toExponential(3);
|
|
||||||
return parseFloat(n.toFixed(4)).toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetView = () => setViewport({ cx: 0, cy: 0, scale: 60 });
|
|
||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
style={{ width, height, position: "relative", userSelect: "none" }}
|
|
||||||
className="rounded-2xl overflow-hidden border border-stone-200 shadow-md bg-stone-50 font-mono pt-32"
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{ display: "block", cursor: "crosshair", touchAction: "none" }}
|
|
||||||
onWheel={onWheel}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerCancel={onPointerUp}
|
|
||||||
onTouchStart={onTouchStart}
|
|
||||||
onTouchMove={onTouchMove}
|
|
||||||
onClick={onCanvasClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Equation legend */}
|
|
||||||
<div className="absolute top-3 left-3 flex flex-col gap-1.5">
|
|
||||||
{equations.map((eq, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-white/80 backdrop-blur-sm border border-stone-200 shadow-sm"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="block w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
eq.color ?? DEFAULT_COLORS[idx % DEFAULT_COLORS.length],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-[11px] text-stone-600 leading-none">
|
|
||||||
{eq.label ?? `y = ${eq.fn}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="absolute top-3 right-3 flex flex-col gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={() => zoom(1.25, canvasSize.w / 2, canvasSize.h / 2)}
|
|
||||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-600 hover:bg-stone-100 transition text-lg flex items-center justify-center"
|
|
||||||
title="Zoom in"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => zoom(1 / 1.25, canvasSize.w / 2, canvasSize.h / 2)}
|
|
||||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-600 hover:bg-stone-100 transition text-lg flex items-center justify-center"
|
|
||||||
title="Zoom out"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={resetView}
|
|
||||||
className="w-8 h-8 rounded-lg bg-white/90 border border-stone-200 shadow-sm text-stone-500 hover:bg-stone-100 transition text-[10px] flex items-center justify-center font-sans"
|
|
||||||
title="Reset view"
|
|
||||||
>
|
|
||||||
⌂
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Intersection tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className="absolute z-10 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
left: tooltip.screenX,
|
|
||||||
top: tooltip.screenY,
|
|
||||||
transform: "translate(-50%, -130%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="bg-stone-900 text-stone-100 text-[11px] px-3 py-2 rounded-xl shadow-xl border border-stone-700 whitespace-nowrap">
|
|
||||||
<div className="font-semibold mb-0.5 text-stone-300 text-[10px] tracking-wide uppercase">
|
|
||||||
Intersection
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
x ={" "}
|
|
||||||
<span className="text-amber-300 font-bold">
|
|
||||||
{fmt(tooltip.mathX)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
y ={" "}
|
|
||||||
<span className="text-amber-300 font-bold">
|
|
||||||
{fmt(tooltip.mathY)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-stone-500 text-[9px] mt-1">
|
|
||||||
eq {tooltip.eqA + 1} ∩ eq {tooltip.eqB + 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Arrow */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<div className="w-2 h-2 bg-stone-900 rotate-45 -mt-1 border-r border-b border-stone-700" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dismiss tooltip on background click hint */}
|
|
||||||
{tooltip && (
|
|
||||||
<button
|
|
||||||
className="absolute inset-0 w-full h-full bg-transparent"
|
|
||||||
onClick={() => setTooltip(null)}
|
|
||||||
style={{ zIndex: 5 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
820
src/components/InfoHeader.tsx
Normal file
820
src/components/InfoHeader.tsx
Normal file
@ -0,0 +1,820 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight, Gauge, Map } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import {
|
||||||
|
useQuestStore,
|
||||||
|
getQuestSummary,
|
||||||
|
getCrewRank,
|
||||||
|
getEarnedXP,
|
||||||
|
} from "../stores/useQuestStore";
|
||||||
|
import type { QuestNode, QuestArc } from "../types/quest";
|
||||||
|
import { CREW_RANKS } from "../types/quest";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
|
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
|
||||||
|
import { PredictedScoreCard } from "./PredictedScoreCard";
|
||||||
|
import { ChestOpenModal } from "./ChestOpenModal";
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
|
||||||
|
|
||||||
|
/* ════ SHARED ANIMATION ════ */
|
||||||
|
@keyframes hcIn {
|
||||||
|
from { opacity:0; transform:translateY(10px) scale(0.97); }
|
||||||
|
to { opacity:1; transform:translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════ WHITE CARD (DEFAULT / LEVEL / QUEST_COMPACT) ════ */
|
||||||
|
.hc-card {
|
||||||
|
background: white;
|
||||||
|
border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 26px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Identity */
|
||||||
|
.hc-top {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
justify-content: space-between; gap: 0.75rem;
|
||||||
|
padding: 1.1rem 1.2rem 0.9rem;
|
||||||
|
}
|
||||||
|
.hc-identity { display: flex; align-items: center; gap: 0.7rem; flex: 1; min-width: 0; }
|
||||||
|
.hc-av-wrap { position: relative; flex-shrink: 0; }
|
||||||
|
.hc-av-pip {
|
||||||
|
position: absolute; bottom: -3px; right: -3px;
|
||||||
|
min-width: 18px; height: 18px; border-radius: 9px; padding: 0 4px;
|
||||||
|
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||||
|
border: 2px solid white;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.55rem; font-weight: 900; color: white;
|
||||||
|
}
|
||||||
|
.hc-nameblock { flex: 1; min-width: 0; }
|
||||||
|
.hc-greeting {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.98rem; font-weight: 900; color: #1e1b4b;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2;
|
||||||
|
}
|
||||||
|
.hc-greeting em { font-style: normal; color: #a855f7; }
|
||||||
|
.hc-role {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.63rem; font-weight: 700; letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase; color: #9ca3af; margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
.hc-score-btn {
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
background: #f7ffe4; border: 2px solid #d9f99d; border-radius: 100px;
|
||||||
|
padding: 0.42rem 0.72rem; font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.76rem; font-weight: 800; color: #65a30d;
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.hc-score-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.07); }
|
||||||
|
.hc-sep { height: 1px; margin: 0 1.2rem; background: #f3f4f6; }
|
||||||
|
|
||||||
|
/* XP bar */
|
||||||
|
.hc-xp-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.2rem; }
|
||||||
|
.hc-lvl-tag {
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 900;
|
||||||
|
color: #a855f7; flex-shrink: 0; background: #f3e8ff;
|
||||||
|
border-radius: 8px; padding: 0.22rem 0.5rem; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hc-bar-wrap { flex: 1; display: flex; flex-direction: column; gap: 0.22rem; }
|
||||||
|
.hc-track { height: 8px; background: #f3f4f6; border-radius: 100px; overflow: hidden; }
|
||||||
|
.hc-fill {
|
||||||
|
height: 100%; border-radius: 100px;
|
||||||
|
background: linear-gradient(90deg, #a855f7, #f97316);
|
||||||
|
transition: width 1.1s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.hc-fill::after {
|
||||||
|
content: ''; position: absolute; inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: hcShimmer 2.6s ease-in-out 1s infinite;
|
||||||
|
}
|
||||||
|
@keyframes hcShimmer { to { transform: translateX(200%); } }
|
||||||
|
.hc-xp-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.6rem; font-weight: 700; color: #9ca3af;
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.hc-xp-label span:first-child { color: #a855f7; font-weight: 900; }
|
||||||
|
|
||||||
|
/* Rank row (compact) */
|
||||||
|
.hc-rank-row {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.75rem 1.2rem; cursor: pointer;
|
||||||
|
transition: background 0.15s; border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.hc-rank-row:first-child { border-top: none; }
|
||||||
|
.hc-rank-row:hover { background: #fafafa; }
|
||||||
|
.hc-rank-emoji { font-size: 1.15rem; flex-shrink: 0; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)); }
|
||||||
|
.hc-rank-text { flex: 1; min-width: 0; }
|
||||||
|
.hc-rank-name {
|
||||||
|
font-family: 'Cinzel', serif; font-size: 0.8rem; font-weight: 700; color: #1e1b4b;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.hc-rank-progress-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif; font-size: 0.58rem; font-weight: 700;
|
||||||
|
color: #9ca3af; margin-top: 0.08rem;
|
||||||
|
}
|
||||||
|
.hc-rank-right { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||||
|
.hc-streak-pill {
|
||||||
|
display: flex; align-items: center; gap: 0.22rem;
|
||||||
|
background: #fff5f5; border: 1.5px solid #fecaca; border-radius: 100px;
|
||||||
|
padding: 0.2rem 0.5rem; font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.7rem; font-weight: 900; color: #ef4444;
|
||||||
|
}
|
||||||
|
.hc-chest-badge {
|
||||||
|
display: flex; align-items: center; gap: 0.18rem;
|
||||||
|
background: #fef3c7; border: 1.5px solid #fde68a; border-radius: 100px;
|
||||||
|
padding: 0.2rem 0.5rem; font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.7rem; font-weight: 900; color: #b45309;
|
||||||
|
animation: hcPop 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes hcPop { 0%,100%{transform:scale(1);} 50%{transform:scale(1.07);} }
|
||||||
|
.hc-chevron { color: #d1d5db; transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s; }
|
||||||
|
.hc-chevron.open { transform: rotate(180deg); color: #a855f7; }
|
||||||
|
|
||||||
|
/* Collapsible quest panel */
|
||||||
|
.hc-quests-wrap {
|
||||||
|
overflow: hidden; max-height: 0;
|
||||||
|
transition: max-height 0.38s cubic-bezier(0.4,0,0.2,1);
|
||||||
|
background: #fafafa; border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.hc-quests-wrap.open { max-height: 480px; }
|
||||||
|
.hc-quest-list { display: flex; flex-direction: column; padding: 0.35rem 0; }
|
||||||
|
.hc-quest-row {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.65rem 1.2rem; cursor: pointer; transition: background 0.13s; position: relative;
|
||||||
|
}
|
||||||
|
.hc-quest-row:hover { background: #f3f4f6; }
|
||||||
|
.hc-quest-row::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 20%; bottom: 20%;
|
||||||
|
width: 3px; border-radius: 0 3px 3px 0; background: var(--ac);
|
||||||
|
}
|
||||||
|
.hc-q-icon {
|
||||||
|
width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1rem; background: white; border: 1.5px solid #f3f4f6;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.hc-quest-row:hover .hc-q-icon { transform: scale(1.08) rotate(-4deg); }
|
||||||
|
.hc-q-icon.claimable { background: #fef3c7; border-color: #fde68a; animation: hcWiggle 2s ease-in-out infinite; }
|
||||||
|
@keyframes hcWiggle { 0%,100%{transform:rotate(0);} 30%{transform:rotate(-7deg) scale(1.05);} 70%{transform:rotate(7deg) scale(1.05);} }
|
||||||
|
.hc-q-body { flex: 1; min-width: 0; }
|
||||||
|
.hc-q-name { font-family: 'Nunito', sans-serif; font-size: 0.8rem; font-weight: 800; color: #1e1b4b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.hc-q-sub { font-family: 'Nunito Sans', sans-serif; font-size: 0.62rem; font-weight: 600; color: #9ca3af; margin-top: 0.1rem; }
|
||||||
|
.hc-q-claimable { font-family: 'Nunito Sans', sans-serif; font-size: 0.62rem; font-weight: 700; color: #d97706; margin-top: 0.1rem; }
|
||||||
|
.hc-claim-btn {
|
||||||
|
padding: 0.28rem 0.62rem; border: none; border-radius: 100px; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.65rem; font-weight: 900; color: #1a0800;
|
||||||
|
box-shadow: 0 2px 0 #d97706; flex-shrink: 0; transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.hc-claim-btn:hover { transform: translateY(-1px); }
|
||||||
|
.hc-claim-btn:active { transform: translateY(1px); }
|
||||||
|
.hc-empty { padding: 1rem 1.2rem; text-align: center; font-family: 'Nunito', sans-serif; font-size: 0.82rem; font-weight: 700; color: #9ca3af; }
|
||||||
|
.hc-map-link {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
||||||
|
padding: 0.6rem 1.2rem; border-top: 1px solid #f3f4f6;
|
||||||
|
cursor: pointer; transition: background 0.13s;
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 800; color: #a855f7;
|
||||||
|
}
|
||||||
|
.hc-map-link:hover { background: #fdf4ff; }
|
||||||
|
|
||||||
|
/* ════ DARK OCEAN CARD (QUEST_EXTENDED) ════ */
|
||||||
|
.hc-ext {
|
||||||
|
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
|
||||||
|
border-radius: 26px; overflow: hidden; position: relative;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06);
|
||||||
|
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated sea shimmer */
|
||||||
|
.hc-ext::before {
|
||||||
|
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(105deg, transparent 55%, rgba(56,189,248,0.018) 56%, transparent 57%),
|
||||||
|
repeating-linear-gradient(75deg, transparent 70%, rgba(56,189,248,0.012) 71%, transparent 72%);
|
||||||
|
background-size: 320% 320%, 260% 260%;
|
||||||
|
animation: hcExtSea 14s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes hcExtSea {
|
||||||
|
0% { background-position: 0% 0%, 100% 0%; }
|
||||||
|
100% { background-position: 100% 100%, 0% 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold orb */
|
||||||
|
.hc-ext::after {
|
||||||
|
content: ''; position: absolute; top: -40px; right: -30px; z-index: 0;
|
||||||
|
width: 180px; height: 180px; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.hc-ext-header {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 1rem 1.2rem 0.3rem;
|
||||||
|
}
|
||||||
|
.hc-ext-title {
|
||||||
|
font-family: 'Cinzel', serif; font-size: 0.6rem; font-weight: 700;
|
||||||
|
letter-spacing: 0.2em; text-transform: uppercase; color: rgba(251,191,36,0.65);
|
||||||
|
}
|
||||||
|
.hc-ext-earned {
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.7rem; font-weight: 900;
|
||||||
|
color: #fbbf24; background: rgba(251,191,36,0.1);
|
||||||
|
border: 1px solid rgba(251,191,36,0.18); border-radius: 100px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable track container */
|
||||||
|
.hc-ext-scroll {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
overflow-x: auto; overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch; scrollbar-width: none;
|
||||||
|
cursor: grab; padding: 1.0rem 1.0rem 0.8rem;
|
||||||
|
}
|
||||||
|
.hc-ext-scroll::-webkit-scrollbar { display: none; }
|
||||||
|
.hc-ext-scroll:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
/* Track inner wrapper — the thing that actually lays out rank nodes */
|
||||||
|
.hc-ext-inner {
|
||||||
|
display: flex; align-items: flex-end;
|
||||||
|
position: relative;
|
||||||
|
/* height: ship(28px) + gap(14px) + node(52px) + label(36px) = ~130px */
|
||||||
|
height: 110px;
|
||||||
|
/* width set inline per node count */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Baseline connector line — full width, dim */
|
||||||
|
.hc-ext-baseline {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px; /* ship(28) + gap(14) + half of node(26) — sits at node centre */
|
||||||
|
left: 26px; right: 26px; height: 2px;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
border-radius: 2px; z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold progress line — width set inline */
|
||||||
|
.hc-ext-progress-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px; left: 26px; height: 2px;
|
||||||
|
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
||||||
|
box-shadow: 0 0 10px rgba(251,191,36,0.5);
|
||||||
|
border-radius: 2px; z-index: 1;
|
||||||
|
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ship — absolutely positioned, transition on 'left' */
|
||||||
|
.hc-ext-ship-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 25px; /* sits at top of inner, ship 28px + gap 14px = 42px to node top (56px centre) */
|
||||||
|
z-index: 10; pointer-events: none;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 0px;
|
||||||
|
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
.hc-ext-ship {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
filter: drop-shadow(0 2px 12px rgba(251,191,36,0.6));
|
||||||
|
animation: hcShipBob 2.8s ease-in-out infinite;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
@keyframes hcShipBob {
|
||||||
|
0%,100% { transform: translateY(0) rotate(-3deg); }
|
||||||
|
50% { transform: translateY(-6px) rotate(3deg); }
|
||||||
|
}
|
||||||
|
.hc-ext-ship-tether {
|
||||||
|
width: 1px; height: 14px;
|
||||||
|
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each rank column */
|
||||||
|
.hc-ext-col {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
width: 88px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* Narrow first/last columns so line extends correctly */
|
||||||
|
.hc-ext-col:first-child,
|
||||||
|
.hc-ext-col:last-child { width: 52px; }
|
||||||
|
|
||||||
|
/* Node circle */
|
||||||
|
.hc-ext-node {
|
||||||
|
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.4rem; position: relative; z-index: 2;
|
||||||
|
margin-top: 42px; /* push down below ship zone */
|
||||||
|
}
|
||||||
|
.hc-ext-node.reached {
|
||||||
|
background: linear-gradient(145deg, #1e0e4a, #3730a3);
|
||||||
|
border: 2px solid rgba(251,191,36,0.45);
|
||||||
|
box-shadow: 0 0 18px rgba(251,191,36,0.2), 0 4px 0 rgba(20,10,50,0.7);
|
||||||
|
}
|
||||||
|
.hc-ext-node.current {
|
||||||
|
background: linear-gradient(145deg, #6d28d9, #a855f7);
|
||||||
|
border: 2.5px solid #fbbf24;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(251,191,36,0.12),
|
||||||
|
0 0 22px rgba(168,85,247,0.45),
|
||||||
|
0 4px 0 rgba(80,30,150,0.5);
|
||||||
|
animation: hcExtNodePulse 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes hcExtNodePulse {
|
||||||
|
0%,100% { box-shadow: 0 0 0 4px rgba(251,191,36,0.12), 0 0 22px rgba(168,85,247,0.45), 0 4px 0 rgba(80,30,150,0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 7px rgba(251,191,36,0.06), 0 0 30px rgba(168,85,247,0.6), 0 4px 0 rgba(80,30,150,0.5); }
|
||||||
|
}
|
||||||
|
.hc-ext-node.locked {
|
||||||
|
background: rgba(0,0,0);
|
||||||
|
border: 2px solid rgba(255,255,255,0.09);
|
||||||
|
filter: grayscale(0.7) opacity(0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels below node */
|
||||||
|
.hc-ext-label {
|
||||||
|
margin-top: 7px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||||
|
}
|
||||||
|
.hc-ext-label-name {
|
||||||
|
font-family: 'Cinzel', serif; font-size: 0.48rem; font-weight: 700;
|
||||||
|
text-align: center; line-height: 1.3; letter-spacing: 0.03em; max-width: 70px;
|
||||||
|
}
|
||||||
|
.hc-ext-label-name.reached { color: #fbbf24; }
|
||||||
|
.hc-ext-label-name.current { color: #c084fc; }
|
||||||
|
.hc-ext-label-name.locked { color: rgba(255,255,255,0.2); }
|
||||||
|
.hc-ext-label-xp {
|
||||||
|
font-family: 'Nunito Sans', sans-serif; font-size: 0.42rem; font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hc-ext-label-xp.reached { color: rgba(251,191,36,0.4); }
|
||||||
|
.hc-ext-label-xp.current { color: rgba(192,132,252,0.6); }
|
||||||
|
.hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
/* Footer link */
|
||||||
|
.hc-ext-footer {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
||||||
|
padding: 0.5rem 1.2rem 0.85rem; margin-top: 0.2rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
cursor: pointer; transition: opacity 0.15s;
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.68rem; font-weight: 800;
|
||||||
|
color: rgba(251,191,36,0.55); letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.hc-ext-footer:hover { opacity: 0.75; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function getActiveQuests(arcs: QuestArc[]) {
|
||||||
|
const out: { node: QuestNode; arc: QuestArc }[] = [];
|
||||||
|
for (const arc of arcs)
|
||||||
|
for (const node of arc.nodes)
|
||||||
|
if (node.status === "claimable" || node.status === "active")
|
||||||
|
out.push({ node, arc });
|
||||||
|
out.sort((a, b) =>
|
||||||
|
a.node.status === "claimable" && b.node.status !== "claimable"
|
||||||
|
? -1
|
||||||
|
: b.node.status === "claimable" && a.node.status !== "claimable"
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
return out.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment width for nodes that aren't first/last
|
||||||
|
const SEG_W = 88;
|
||||||
|
const EDGE_W = 52;
|
||||||
|
// Centre x of node at index i (0-based, total N nodes)
|
||||||
|
function nodeX(i: number, total: number): number {
|
||||||
|
if (i === 0) return EDGE_W / 2;
|
||||||
|
if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
|
||||||
|
return EDGE_W + SEG_W * (i - 1) + SEG_W / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
|
||||||
|
const RankLadder = ({
|
||||||
|
earnedXP,
|
||||||
|
onViewAll,
|
||||||
|
}: {
|
||||||
|
earnedXP: number;
|
||||||
|
onViewAll: () => void;
|
||||||
|
}) => {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const ladder = [...CREW_RANKS] as typeof CREW_RANKS;
|
||||||
|
const N = ladder.length;
|
||||||
|
|
||||||
|
// Which rank the user is currently on (0-based)
|
||||||
|
let currentIdx = 0;
|
||||||
|
for (let i = N - 1; i >= 0; i--) {
|
||||||
|
if (earnedXP >= ladder[i].xpRequired) {
|
||||||
|
currentIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = ladder[currentIdx];
|
||||||
|
const nextRank = ladder[currentIdx + 1] ?? null;
|
||||||
|
const progressToNext = nextRank
|
||||||
|
? Math.min(
|
||||||
|
1,
|
||||||
|
(earnedXP - current.xpRequired) /
|
||||||
|
(nextRank.xpRequired - current.xpRequired),
|
||||||
|
)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
// Ship x position: interpolate between current node and next node
|
||||||
|
const shipX = nextRank
|
||||||
|
? nodeX(currentIdx, N) +
|
||||||
|
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext
|
||||||
|
: nodeX(currentIdx, N);
|
||||||
|
|
||||||
|
// Gold progress line width: from left edge to ship position
|
||||||
|
const progressLineW = shipX;
|
||||||
|
|
||||||
|
// Total scroll width
|
||||||
|
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
|
||||||
|
|
||||||
|
// Animate ship in after mount
|
||||||
|
const [animated, setAnimated] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => setAnimated(true)),
|
||||||
|
);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll to ship position on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
const el = scrollRef.current;
|
||||||
|
const containerW = el.offsetWidth;
|
||||||
|
const targetScroll = shipX - containerW / 2;
|
||||||
|
el.scrollTo({ left: Math.max(0, targetScroll), behavior: "smooth" });
|
||||||
|
}, [shipX]);
|
||||||
|
|
||||||
|
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
|
||||||
|
const nextLabel = nextRank
|
||||||
|
? `${rankPct}% · ${nextRank.xpRequired - earnedXP} XP to ${nextRank.label}`
|
||||||
|
: "Maximum rank achieved";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hc-ext">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="hc-ext-header">
|
||||||
|
<span className="hc-ext-title">⚓ Crew Rank</span>
|
||||||
|
<span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current rank label */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 2,
|
||||||
|
padding: "0 1.2rem 0.1rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: "0.4rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Cinzel', serif",
|
||||||
|
fontSize: "1.05rem",
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "#fbbf24",
|
||||||
|
textShadow: "0 0 18px rgba(251,191,36,0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{current.emoji} {current.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Nunito Sans', sans-serif",
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "rgba(255,255,255,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nextLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable rank track */}
|
||||||
|
<div className="hc-ext-scroll" ref={scrollRef}>
|
||||||
|
<div className="hc-ext-inner" style={{ width: totalW }}>
|
||||||
|
{/* Baseline dim line */}
|
||||||
|
<div className="hc-ext-baseline" />
|
||||||
|
|
||||||
|
{/* Gold progress line */}
|
||||||
|
<div
|
||||||
|
className="hc-ext-progress-line"
|
||||||
|
style={{ width: animated ? progressLineW : 26 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ship marker */}
|
||||||
|
<div
|
||||||
|
className="hc-ext-ship-wrap"
|
||||||
|
style={{ left: animated ? shipX : nodeX(0, N) }}
|
||||||
|
>
|
||||||
|
<span className="hc-ext-ship" role="img" aria-label="ship">
|
||||||
|
⛵
|
||||||
|
</span>
|
||||||
|
<div className="hc-ext-ship-tether" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rank nodes */}
|
||||||
|
{ladder.map((r, i) => {
|
||||||
|
const state =
|
||||||
|
i < currentIdx
|
||||||
|
? "reached"
|
||||||
|
: i === currentIdx
|
||||||
|
? "current"
|
||||||
|
: "locked";
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="hc-ext-col">
|
||||||
|
<div className={`hc-ext-node ${state}`}>{r.emoji}</div>
|
||||||
|
<div className="hc-ext-label">
|
||||||
|
<span className={`hc-ext-label-name ${state}`}>
|
||||||
|
{r.label}
|
||||||
|
</span>
|
||||||
|
<span className={`hc-ext-label-xp ${state}`}>
|
||||||
|
{r.xpRequired === 0
|
||||||
|
? "Start"
|
||||||
|
: `${r.xpRequired.toLocaleString()} XP`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{/* <div className="hc-ext-footer" onClick={onViewAll}>
|
||||||
|
<Map size={12} />
|
||||||
|
View quest map
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onViewAll?: () => void;
|
||||||
|
mode?: Mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const arcs = useQuestStore((s) => s.arcs);
|
||||||
|
const claimNode = useQuestStore((s) => s.claimNode);
|
||||||
|
|
||||||
|
const summary = getQuestSummary(arcs);
|
||||||
|
const rank = getCrewRank(arcs);
|
||||||
|
const earnedXP = getEarnedXP(arcs);
|
||||||
|
const activeQuests = getActiveQuests(arcs);
|
||||||
|
|
||||||
|
const u = user as any;
|
||||||
|
const level = u?.current_level ?? u?.level ?? 1;
|
||||||
|
const totalXP = u?.total_xp ?? u?.xp ?? 0;
|
||||||
|
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
|
||||||
|
const levelEnd =
|
||||||
|
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
|
||||||
|
const streak = u?.streak ?? u?.current_streak ?? 0;
|
||||||
|
const firstName = user?.name?.split(" ")[0] || "there";
|
||||||
|
const roleLabel =
|
||||||
|
u?.role === "ADMIN"
|
||||||
|
? "Admin"
|
||||||
|
: u?.role === "TEACHER"
|
||||||
|
? "Teacher"
|
||||||
|
: "Student";
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const timeLabel = hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening";
|
||||||
|
|
||||||
|
const levelRange = Math.max(levelEnd - levelStart, 1);
|
||||||
|
const xpIntoLevel = Math.max(totalXP - levelStart, 0);
|
||||||
|
const rawPct = Math.min(Math.round((xpIntoLevel / levelRange) * 100), 100);
|
||||||
|
const xpToGo = Math.max(levelEnd - totalXP, 0);
|
||||||
|
|
||||||
|
const [barPct, setBarPct] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => setBarPct(rawPct)),
|
||||||
|
);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [rawPct]);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [claimingNode, setClaimingNode] = useState<{
|
||||||
|
node: QuestNode;
|
||||||
|
arcId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleViewAll = () => {
|
||||||
|
if (onViewAll) onViewAll();
|
||||||
|
else navigate("/student/quests");
|
||||||
|
};
|
||||||
|
const handleClaim = (node: QuestNode, arcId: string) =>
|
||||||
|
setClaimingNode({ node, arcId });
|
||||||
|
const handleChestClose = () => {
|
||||||
|
if (!claimingNode) return;
|
||||||
|
claimNode(claimingNode.arcId, claimingNode.node.id);
|
||||||
|
setClaimingNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rankProgress = Math.round(rank.progressToNext * 100);
|
||||||
|
const nextLabel = rank.next
|
||||||
|
? `${rankProgress}% to ${rank.next.label}`
|
||||||
|
: "Max rank";
|
||||||
|
|
||||||
|
const showIdentity = mode === "DEFAULT";
|
||||||
|
const showLevel = mode === "DEFAULT" || mode === "LEVEL";
|
||||||
|
const showQuestCompact = mode === "DEFAULT" || mode === "QUEST_COMPACT";
|
||||||
|
const showQuestExtended = mode === "QUEST_EXTENDED";
|
||||||
|
|
||||||
|
// QUEST_EXTENDED renders its own standalone dark card — no .hc-card wrapper
|
||||||
|
if (showQuestExtended) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{STYLES}</style>
|
||||||
|
<RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} />
|
||||||
|
{claimingNode && (
|
||||||
|
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
|
<div className="hc-card">
|
||||||
|
{/* Identity — DEFAULT only */}
|
||||||
|
{showIdentity && (
|
||||||
|
<>
|
||||||
|
<div className="hc-top">
|
||||||
|
<div className="hc-identity">
|
||||||
|
<div className="hc-av-wrap">
|
||||||
|
<Avatar style={{ width: 46, height: 46, display: "block" }}>
|
||||||
|
<AvatarImage src={u?.avatar_url} />
|
||||||
|
<AvatarFallback
|
||||||
|
style={{
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: "1rem",
|
||||||
|
color: "white",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
background: "linear-gradient(135deg,#a855f7,#7c3aed)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.name?.slice(0, 1)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="hc-av-pip">{level}</div>
|
||||||
|
</div>
|
||||||
|
<div className="hc-nameblock">
|
||||||
|
<p className="hc-greeting">
|
||||||
|
Good {timeLabel}, <em>{firstName}</em> 👋
|
||||||
|
</p>
|
||||||
|
<p className="hc-role">{roleLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Drawer direction="top">
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<button className="hc-score-btn">
|
||||||
|
<Gauge size={14} /> Score
|
||||||
|
</button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<PredictedScoreCard />
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
<div className="hc-sep" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* XP bar — DEFAULT + LEVEL */}
|
||||||
|
{showLevel && (
|
||||||
|
<div className="hc-xp-row">
|
||||||
|
<span className="hc-lvl-tag">Lv {level}</span>
|
||||||
|
<div className="hc-bar-wrap">
|
||||||
|
<div className="hc-track">
|
||||||
|
<div className="hc-fill" style={{ width: `${barPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="hc-xp-label">
|
||||||
|
<span>{totalXP.toLocaleString()} XP</span>
|
||||||
|
<span>{xpToGo.toLocaleString()} to go</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rank + collapsible quests — DEFAULT + QUEST_COMPACT */}
|
||||||
|
{showQuestCompact && (
|
||||||
|
<>
|
||||||
|
<div className="hc-rank-row" onClick={() => setOpen((o) => !o)}>
|
||||||
|
<span className="hc-rank-emoji">{rank.emoji}</span>
|
||||||
|
<div className="hc-rank-text">
|
||||||
|
<p className="hc-rank-name">{rank.label}</p>
|
||||||
|
<p className="hc-rank-progress-label">{nextLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div className="hc-rank-right">
|
||||||
|
{streak > 0 && (
|
||||||
|
<span className="hc-streak-pill">🔥 {streak}</span>
|
||||||
|
)}
|
||||||
|
{summary.claimableNodes > 0 && (
|
||||||
|
<span className="hc-chest-badge">
|
||||||
|
📦 {summary.claimableNodes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`hc-chevron${open ? " open" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`hc-quests-wrap${open ? " open" : ""}`}>
|
||||||
|
<div className="hc-quest-list">
|
||||||
|
{activeQuests.length === 0 ? (
|
||||||
|
<p className="hc-empty">⚓ All caught up — keep sailing!</p>
|
||||||
|
) : (
|
||||||
|
activeQuests.map(({ node, arc }) => {
|
||||||
|
const pct = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(
|
||||||
|
(node.progress / node.requirement.target) * 100,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const isClaimable = node.status === "claimable";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className="hc-quest-row"
|
||||||
|
style={
|
||||||
|
{ "--ac": arc.accentColor } as React.CSSProperties
|
||||||
|
}
|
||||||
|
onClick={() => !isClaimable && handleViewAll()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`hc-q-icon${isClaimable ? " claimable" : ""}`}
|
||||||
|
>
|
||||||
|
{isClaimable ? "📦" : node.emoji}
|
||||||
|
</div>
|
||||||
|
<div className="hc-q-body">
|
||||||
|
<p className="hc-q-name">{node.title}</p>
|
||||||
|
{isClaimable ? (
|
||||||
|
<p className="hc-q-claimable">✨ Ready to claim!</p>
|
||||||
|
) : (
|
||||||
|
<p className="hc-q-sub">
|
||||||
|
{node.progress}/{node.requirement.target}{" "}
|
||||||
|
{node.requirement.label} · {pct}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isClaimable ? (
|
||||||
|
<button
|
||||||
|
className="hc-claim-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClaim(node, arc.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} color="#d1d5db" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hc-map-link" onClick={handleViewAll}>
|
||||||
|
<Map size={13} />
|
||||||
|
View quest map
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claimingNode && (
|
||||||
|
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,13 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader } from "../components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from "../components/ui/dialog";
|
|
||||||
import { api } from "../utils/api";
|
import { api } from "../utils/api";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import { Loader, X } from "lucide-react";
|
||||||
|
|
||||||
interface LessonModalProps {
|
interface LessonModalProps {
|
||||||
lessonId: string | null;
|
lessonId: string | null;
|
||||||
@ -15,6 +10,107 @@ interface LessonModalProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
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 = ({
|
export const LessonModal = ({
|
||||||
lessonId,
|
lessonId,
|
||||||
open,
|
open,
|
||||||
@ -26,21 +122,15 @@ export const LessonModal = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !lessonId || !user) return;
|
if (!open || !lessonId || !user) return;
|
||||||
|
|
||||||
const fetchLesson = async () => {
|
const fetchLesson = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const authStorage = localStorage.getItem("auth-storage");
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
if (!authStorage) return;
|
if (!authStorage) return;
|
||||||
|
const {
|
||||||
const parsed = JSON.parse(authStorage) as {
|
state: { token },
|
||||||
state?: { token?: string };
|
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||||
};
|
|
||||||
|
|
||||||
const token = parsed.state?.token;
|
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const response = await api.fetchLessonById(token, lessonId);
|
const response = await api.fetchLessonById(token, lessonId);
|
||||||
setLesson(response);
|
setLesson(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -49,40 +139,73 @@ export const LessonModal = ({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchLesson();
|
fetchLesson();
|
||||||
}, [open, lessonId, user]);
|
}, [open, lessonId, user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-3xl">
|
<style>{STYLES}</style>
|
||||||
{loading && (
|
<DialogContent className="lm-content" showCloseButton={false}>
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
<DialogHeader style={{ display: "none" }} />
|
||||||
Loading lesson...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{lesson ? lesson.title : "Lesson details"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{!loading && lesson && (
|
{/* Header */}
|
||||||
<div className="space-y-4">
|
<div className="lm-header">
|
||||||
{lesson.video_url && (
|
<div className="lm-title-wrap">
|
||||||
<video
|
<span className="lm-eyebrow">📖 Lesson</span>
|
||||||
src={lesson.video_url}
|
<h2 className="lm-title">
|
||||||
controls
|
{loading ? "Loading..." : (lesson?.title ?? "Lesson details")}
|
||||||
className="w-full rounded-lg"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<h2 className="font-satoshi-bold text-xl">
|
|
||||||
{lesson ? lesson.title : "Lesson details"}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{lesson.description}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{lesson.content}</p>
|
|
||||||
</div>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
93
src/components/LevelBar.tsx
Normal file
93
src/components/LevelBar.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
.lb-wrap {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #f3f4f6;
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 0.38rem 0.75rem 0.38rem 0.42rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||||
|
animation: lbIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes lbIn {
|
||||||
|
from { opacity:0; transform: scale(0.9) translateX(6px); }
|
||||||
|
to { opacity:1; transform: scale(1) translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Level bubble */
|
||||||
|
.lb-bubble {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 2px 0 #5b21b644;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.7rem; font-weight: 900; color: white;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bar track */
|
||||||
|
.lb-track {
|
||||||
|
width: 80px; height: 7px;
|
||||||
|
background: #f3f4f6; border-radius: 100px; overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lb-fill {
|
||||||
|
height: 100%; border-radius: 100px;
|
||||||
|
background: linear-gradient(90deg, #a855f7, #f97316);
|
||||||
|
transition: width 1s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.lb-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: lbShimmer 2.2s ease-in-out 1s infinite;
|
||||||
|
}
|
||||||
|
@keyframes lbShimmer { to { transform: translateX(200%); } }
|
||||||
|
|
||||||
|
/* XP label */
|
||||||
|
.lb-label {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.68rem; font-weight: 900;
|
||||||
|
color: #a855f7; white-space: nowrap;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LevelBar = () => {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const u = user as any;
|
||||||
|
|
||||||
|
const level = u?.current_level ?? u?.level ?? 1;
|
||||||
|
const totalXP = u?.total_xp ?? u?.xp ?? 0;
|
||||||
|
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
|
||||||
|
const levelEnd =
|
||||||
|
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
|
||||||
|
|
||||||
|
const levelRange = Math.max(levelEnd - levelStart, 1);
|
||||||
|
const xpIntoLevel = Math.max(totalXP - levelStart, 0);
|
||||||
|
const rawPct = Math.min(Math.round((xpIntoLevel / levelRange) * 100), 100);
|
||||||
|
|
||||||
|
const [pct, setPct] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => setPct(rawPct)),
|
||||||
|
);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [rawPct]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{STYLES}</style>
|
||||||
|
<div className="lb-wrap">
|
||||||
|
<div className="lb-bubble">{level}</div>
|
||||||
|
<div className="lb-track">
|
||||||
|
<div className="lb-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="lb-label">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,11 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "../components/ui/card";
|
|
||||||
import { api } from "../utils/api";
|
import { api } from "../utils/api";
|
||||||
import { useAuthToken } from "../hooks/useAuthToken";
|
import { useAuthToken } from "../hooks/useAuthToken";
|
||||||
import {
|
import {
|
||||||
@ -36,34 +29,38 @@ interface PredictedScoreResponse {
|
|||||||
|
|
||||||
const confidenceConfig: Record<
|
const confidenceConfig: Record<
|
||||||
string,
|
string,
|
||||||
{ label: string; color: string; bg: string; dot: string }
|
{ label: string; color: string; bg: string; border: string; dot: string }
|
||||||
> = {
|
> = {
|
||||||
high: {
|
high: {
|
||||||
label: "High confidence",
|
label: "High confidence",
|
||||||
color: "text-emerald-700",
|
color: "#16a34a",
|
||||||
bg: "bg-emerald-50 border-emerald-200",
|
bg: "#f0fdf4",
|
||||||
dot: "bg-emerald-500",
|
border: "#bbf7d0",
|
||||||
|
dot: "#22c55e",
|
||||||
},
|
},
|
||||||
medium: {
|
medium: {
|
||||||
label: "Medium confidence",
|
label: "Medium confidence",
|
||||||
color: "text-amber-700",
|
color: "#d97706",
|
||||||
bg: "bg-amber-50 border-amber-200",
|
bg: "#fffbeb",
|
||||||
dot: "bg-amber-400",
|
border: "#fde68a",
|
||||||
|
dot: "#f59e0b",
|
||||||
},
|
},
|
||||||
low: {
|
low: {
|
||||||
label: "Low confidence",
|
label: "Low confidence",
|
||||||
color: "text-rose-700",
|
color: "#e11d48",
|
||||||
bg: "bg-rose-50 border-rose-200",
|
bg: "#fff1f2",
|
||||||
dot: "bg-rose-400",
|
border: "#fecdd3",
|
||||||
|
dot: "#f43f5e",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const getConfidenceStyle = (confidence: string) =>
|
const getConfidenceStyle = (confidence: string) =>
|
||||||
confidenceConfig[confidence.toLowerCase()] ?? {
|
confidenceConfig[confidence.toLowerCase()] ?? {
|
||||||
label: confidence,
|
label: confidence,
|
||||||
color: "text-gray-600",
|
color: "#6b7280",
|
||||||
bg: "bg-gray-50 border-gray-200",
|
bg: "#f9fafb",
|
||||||
dot: "bg-gray-400",
|
border: "#f3f4f6",
|
||||||
|
dot: "#9ca3af",
|
||||||
};
|
};
|
||||||
|
|
||||||
const useCountUp = (target: number, duration = 900) => {
|
const useCountUp = (target: number, duration = 900) => {
|
||||||
@ -83,77 +80,265 @@ const useCountUp = (target: number, duration = 900) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Expanded section detail ──────────────────────────────────────────────────
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
.psc-card {
|
||||||
|
background: white;
|
||||||
|
border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.psc-header {
|
||||||
|
padding: 1.1rem 1.25rem 0.75rem;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
border-bottom: 2px solid #f9fafb;
|
||||||
|
}
|
||||||
|
.psc-header-left { display:flex;flex-direction:column;gap:0.15rem; }
|
||||||
|
.psc-header-title {
|
||||||
|
font-size: 0.88rem; font-weight: 900; color: #1e1b4b;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.psc-header-sub {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.7rem; font-weight: 600; color: #9ca3af;
|
||||||
|
}
|
||||||
|
.psc-header-icon {
|
||||||
|
width: 36px; height: 36px; border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 4px 0 #5b21b644;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.psc-body { padding: 1.1rem 1.25rem; display:flex;flex-direction:column;gap:0.85rem; }
|
||||||
|
|
||||||
|
/* Scores row */
|
||||||
|
.psc-scores-row {
|
||||||
|
display: flex; align-items: stretch; gap: 0;
|
||||||
|
background: #fafaf9; border: 2px solid #f3f4f6;
|
||||||
|
border-radius: 18px; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psc-score-cell {
|
||||||
|
flex: 1; display:flex;flex-direction:column;align-items:center;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.psc-score-cell + .psc-score-cell::before {
|
||||||
|
content:''; position:absolute; left:0; top:20%; bottom:20%;
|
||||||
|
width:2px; background:#f3f4f6; border-radius:2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Total cell — slightly different bg */
|
||||||
|
.psc-score-cell.total {
|
||||||
|
background: white;
|
||||||
|
border-right: 2px solid #f3f4f6;
|
||||||
|
flex: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psc-cell-label {
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: #9ca3af; margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.psc-cell-score {
|
||||||
|
font-weight: 900; color: #1e1b4b; line-height: 1;
|
||||||
|
}
|
||||||
|
.psc-cell-score.large { font-size: 2.8rem; }
|
||||||
|
.psc-cell-score.medium { font-size: 1.7rem; }
|
||||||
|
.psc-cell-out {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.62rem; font-weight: 600; color: #d1d5db;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle button */
|
||||||
|
.psc-toggle-btn {
|
||||||
|
width: 100%; display:flex;align-items:center;justify-content:center;gap:0.4rem;
|
||||||
|
padding: 0.55rem; border-radius: 12px; border: 2px solid #f3f4f6;
|
||||||
|
background: white; cursor: pointer;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.72rem; font-weight: 800; color: #9ca3af;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.psc-toggle-btn:hover { border-color: #e9d5ff; color: #a855f7; background: #fdf4ff; }
|
||||||
|
|
||||||
|
/* Section detail cards */
|
||||||
|
.psc-detail-card {
|
||||||
|
background: #fafaf9; border: 2.5px solid #f3f4f6; border-radius: 18px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
display: flex; flex-direction: column; gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psc-detail-top {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.psc-detail-icon-wrap {
|
||||||
|
width: 30px; height: 30px; border-radius: 10px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.psc-detail-label {
|
||||||
|
font-size: 0.8rem; font-weight: 900; color: #1e1b4b; flex: 1;
|
||||||
|
}
|
||||||
|
.psc-conf-badge {
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.6rem; border-radius: 100px; border: 2px solid;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.6rem; font-weight: 700; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.psc-conf-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0; }
|
||||||
|
|
||||||
|
.psc-score-range-row {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.psc-detail-score {
|
||||||
|
font-size: 1.6rem; font-weight: 900; color: #1e1b4b; line-height: 1;
|
||||||
|
}
|
||||||
|
.psc-range-text {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.68rem; font-weight: 600; color: #9ca3af;
|
||||||
|
text-align: right; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.psc-range-text span { font-weight: 800; color: #6b7280; }
|
||||||
|
|
||||||
|
/* Range bar */
|
||||||
|
.psc-bar-wrap {
|
||||||
|
height: 8px; border-radius: 100px; background: #f3f4f6;
|
||||||
|
position: relative; overflow: visible;
|
||||||
|
}
|
||||||
|
.psc-bar-fill {
|
||||||
|
position: absolute; height: 100%; border-radius: 100px; opacity: 0.4;
|
||||||
|
}
|
||||||
|
.psc-bar-dot {
|
||||||
|
position: absolute; width: 14px; height: 14px;
|
||||||
|
border-radius: 50%; border: 2.5px solid white;
|
||||||
|
top: 50%; transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.psc-bar-labels {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.58rem; font-weight: 600; color: #d1d5db;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded animation */
|
||||||
|
.psc-expanded-wrap {
|
||||||
|
display: flex; flex-direction: column; gap: 0.6rem;
|
||||||
|
animation: pscFadeIn 0.3s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes pscFadeIn {
|
||||||
|
from { opacity:0; transform:translateY(-8px); }
|
||||||
|
to { opacity:1; transform:translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.psc-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 0.5rem; padding: 2rem;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.82rem; font-weight: 600; color: #9ca3af;
|
||||||
|
}
|
||||||
|
.psc-spinner { animation: pscSpin 0.8s linear infinite; }
|
||||||
|
@keyframes pscSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.psc-error {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.82rem; font-weight: 700; color: #e11d48;
|
||||||
|
text-align: center; padding: 1.5rem;
|
||||||
|
background: #fff1f2; border-radius: 14px; border: 2px solid #fecdd3;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Section detail ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SectionDetail = ({
|
const SectionDetail = ({
|
||||||
label,
|
label,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
prediction,
|
prediction,
|
||||||
accentClass,
|
iconBg,
|
||||||
|
barColor,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
prediction: SectionPrediction;
|
prediction: SectionPrediction;
|
||||||
accentClass: string;
|
iconBg: string;
|
||||||
|
barColor: string;
|
||||||
}) => {
|
}) => {
|
||||||
const conf = getConfidenceStyle(prediction.confidence);
|
const conf = getConfidenceStyle(prediction.confidence);
|
||||||
|
const pct = (v: number) => ((v - 200) / (800 - 200)) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 rounded-2xl border border-gray-100 bg-gray-50 px-4 py-4">
|
<div className="psc-detail-card">
|
||||||
<div className="flex items-center justify-between">
|
<div className="psc-detail-top">
|
||||||
<div className="flex items-center gap-2">
|
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
|
||||||
<div className={`p-1.5 rounded-lg ${accentClass}`}>
|
<Icon size={15} color={barColor} />
|
||||||
<Icon size={14} className="text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-satoshi-medium text-sm text-gray-700">
|
<span className="psc-detail-label">{label}</span>
|
||||||
{label}
|
<div
|
||||||
</span>
|
className="psc-conf-badge"
|
||||||
</div>
|
style={{
|
||||||
<span
|
background: conf.bg,
|
||||||
className={`flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full border font-satoshi ${conf.bg} ${conf.color}`}
|
borderColor: conf.border,
|
||||||
|
color: conf.color,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${conf.dot}`} />
|
<div className="psc-conf-dot" style={{ background: conf.dot }} />
|
||||||
{conf.label}
|
{conf.label}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end justify-between mt-1">
|
<div className="psc-score-range-row">
|
||||||
<span className="font-satoshi-bold text-2xl text-gray-900">
|
<span className="psc-detail-score">{prediction.score}</span>
|
||||||
{prediction.score}
|
<div className="psc-range-text">
|
||||||
</span>
|
<span>Range</span>
|
||||||
<span className="font-satoshi text-xs text-gray-400 mb-1">
|
<br />
|
||||||
Range:{" "}
|
<span>
|
||||||
<span className="text-gray-600 font-satoshi-medium">
|
|
||||||
{prediction.range_min}–{prediction.range_max}
|
{prediction.range_min}–{prediction.range_max}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Range bar */}
|
<div>
|
||||||
<div className="relative h-1.5 rounded-full bg-gray-200 mt-1">
|
<div className="psc-bar-wrap">
|
||||||
<div
|
<div
|
||||||
className={`absolute h-1.5 rounded-full ${accentClass} opacity-60`}
|
className="psc-bar-fill"
|
||||||
style={{
|
style={{
|
||||||
left: `${((prediction.range_min - 200) / (800 - 200)) * 100}%`,
|
left: `${pct(prediction.range_min)}%`,
|
||||||
right: `${100 - ((prediction.range_max - 200) / (800 - 200)) * 100}%`,
|
right: `${100 - pct(prediction.range_max)}%`,
|
||||||
|
background: barColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute w-2.5 h-2.5 rounded-full border-2 border-white ${accentClass} -top-0.5 shadow-sm`}
|
className="psc-bar-dot"
|
||||||
style={{
|
style={{
|
||||||
left: `calc(${((prediction.score - 200) / (800 - 200)) * 100}% - 5px)`,
|
left: `${pct(prediction.score)}%`,
|
||||||
|
background: barColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[10px] text-gray-300 font-satoshi mt-0.5">
|
<div className="psc-bar-labels">
|
||||||
<span>200</span>
|
<span>200</span>
|
||||||
<span>800</span>
|
<span>800</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Main component ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let stylesInjected = false;
|
||||||
|
|
||||||
export const PredictedScoreCard = () => {
|
export const PredictedScoreCard = () => {
|
||||||
const token = useAuthToken();
|
const token = useAuthToken();
|
||||||
const [data, setData] = useState<PredictedScoreResponse | null>(null);
|
const [data, setData] = useState<PredictedScoreResponse | null>(null);
|
||||||
@ -177,129 +362,113 @@ export const PredictedScoreCard = () => {
|
|||||||
})();
|
})();
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
if (!stylesInjected) {
|
||||||
|
const tag = document.createElement("style");
|
||||||
|
tag.textContent = STYLES;
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
stylesInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
const animatedTotal = useCountUp(data?.total_score ?? 0, 1000);
|
const animatedTotal = useCountUp(data?.total_score ?? 0, 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full border border-gray-200 shadow-sm overflow-hidden">
|
<div className="psc-card">
|
||||||
<CardHeader>
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="psc-header">
|
||||||
<div>
|
<div className="psc-header-left">
|
||||||
<CardTitle className="font-satoshi-bold text-lg text-gray-900">
|
<p className="psc-header-title">Predicted SAT Score</p>
|
||||||
Predicted SAT Score
|
<p className="psc-header-sub">Based on your practice performance</p>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="font-satoshi text-sm text-gray-400 mt-0.5">
|
|
||||||
Based on your practice performance
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 rounded-xl bg-purple-50 border border-purple-100">
|
<div className="psc-header-icon">
|
||||||
<TrendingUp size={18} className="text-purple-500" />
|
<TrendingUp size={17} color="white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
{/* Body */}
|
||||||
|
<div className="psc-body">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="psc-loading">
|
||||||
<Loader2 size={26} className="animate-spin text-purple-400" />
|
<Loader2 size={20} color="#a855f7" className="psc-spinner" />
|
||||||
|
Calculating your score...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !loading && (
|
{error && !loading && <div className="psc-error">⚠️ {error}</div>}
|
||||||
<p className="font-satoshi text-sm text-rose-500 text-center py-4">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && !loading && (
|
{data && !loading && (
|
||||||
<>
|
<>
|
||||||
{/* ── Collapsed view: big numbers only ── */}
|
{/* Score cells */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="psc-scores-row">
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
<div className="flex flex-col">
|
<div className="psc-score-cell total">
|
||||||
<span className="font-satoshi text-lg text-gray-400 mb-0.5">
|
<div className="psc-cell-label">
|
||||||
Total
|
<TrendingUp size={10} color="#a855f7" /> Total
|
||||||
</span>
|
</div>
|
||||||
<span className="font-satoshi-bold text-6xl text-gray-900 leading-none">
|
<span className="psc-cell-score large">{animatedTotal}</span>
|
||||||
{animatedTotal}
|
<span className="psc-cell-out">/ 1600</span>
|
||||||
</span>
|
|
||||||
<span className="font-satoshi text-[18px] text-gray-300 mt-1">
|
|
||||||
out of 1600
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-12 w-px bg-gray-100" />
|
|
||||||
|
|
||||||
{/* Math */}
|
{/* Math */}
|
||||||
<div className="flex flex-col items-center">
|
<div className="psc-score-cell">
|
||||||
<div className="flex items-center gap-1 mb-0.5">
|
<div className="psc-cell-label">
|
||||||
<Calculator size={16} className="text-violet-400" />
|
<Calculator size={10} color="#7c3aed" /> Math
|
||||||
<span className="font-satoshi text-sm text-gray-400">
|
|
||||||
Math
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
|
<span className="psc-cell-score medium">
|
||||||
{data.math_prediction.score}
|
{data.math_prediction.score}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
|
<span className="psc-cell-out">/ 800</span>
|
||||||
out of 800
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-12 w-px bg-gray-100" />
|
|
||||||
|
|
||||||
{/* R&W */}
|
{/* R&W */}
|
||||||
<div className="flex flex-col items-center">
|
<div className="psc-score-cell">
|
||||||
<div className="flex items-center gap-1 mb-0.5">
|
<div className="psc-cell-label">
|
||||||
<BookOpen size={16} className="text-sky-400" />
|
<BookOpen size={10} color="#0891b2" /> R&W
|
||||||
<span className="font-satoshi text-sm text-gray-400">
|
|
||||||
R&W
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-satoshi-bold text-3xl text-gray-900 leading-none">
|
<span className="psc-cell-score medium">
|
||||||
{data.rw_prediction.score}
|
{data.rw_prediction.score}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-satoshi text-[12px] text-gray-300 mt-1">
|
<span className="psc-cell-out">/ 800</span>
|
||||||
out of 800
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Expand toggle ── */}
|
{/* Toggle */}
|
||||||
<button
|
<button
|
||||||
|
className="psc-toggle-btn"
|
||||||
onClick={() => setExpanded((p) => !p)}
|
onClick={() => setExpanded((p) => !p)}
|
||||||
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs font-satoshi-medium text-gray-400 hover:text-purple-500 transition-colors"
|
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp size={14} /> Less detail
|
<ChevronUp size={13} /> Less detail
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChevronDown size={14} /> More detail
|
<ChevronDown size={13} /> Score breakdown
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* ── Expanded: range bars + confidence ── */}
|
{/* Expanded */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="space-y-3 pt-1">
|
<div className="psc-expanded-wrap">
|
||||||
<SectionDetail
|
<SectionDetail
|
||||||
label="Math"
|
label="Mathematics"
|
||||||
icon={Calculator}
|
icon={Calculator}
|
||||||
prediction={data.math_prediction}
|
prediction={data.math_prediction}
|
||||||
accentClass="bg-violet-500"
|
iconBg="#fdf4ff"
|
||||||
|
barColor="#a855f7"
|
||||||
/>
|
/>
|
||||||
<SectionDetail
|
<SectionDetail
|
||||||
label="Reading & Writing"
|
label="Reading & Writing"
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
prediction={data.rw_prediction}
|
prediction={data.rw_prediction}
|
||||||
accentClass="bg-sky-500"
|
iconBg="#ecfeff"
|
||||||
|
barColor="#0891b2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
1076
src/components/QuestNodeModal.tsx
Normal file
1076
src/components/QuestNodeModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
507
src/components/QuestProgressCard.tsx
Normal file
507
src/components/QuestProgressCard.tsx
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { QuestNode, QuestArc } from "../types/quest";
|
||||||
|
import { CREW_RANKS } from "../types/quest";
|
||||||
|
import {
|
||||||
|
useQuestStore,
|
||||||
|
getQuestSummary,
|
||||||
|
getCrewRank,
|
||||||
|
} from "../stores/useQuestStore";
|
||||||
|
import { ChestOpenModal } from "./ChestOpenModal";
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
/* ══ CARD SHELL ══ */
|
||||||
|
.qpc2-card {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
|
||||||
|
border: 1.5px solid rgba(251,191,36,0.2);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0,0,0,0.35),
|
||||||
|
0 0 0 1px rgba(255,255,255,0.04) inset,
|
||||||
|
0 1px 0 rgba(255,255,255,0.08) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated sea shimmer behind everything */
|
||||||
|
.qpc2-sea {
|
||||||
|
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(105deg, transparent 0%, transparent 55%,
|
||||||
|
rgba(56,189,248,0.022) 56%, transparent 57%),
|
||||||
|
repeating-linear-gradient(75deg, transparent 0%, transparent 70%,
|
||||||
|
rgba(56,189,248,0.014) 71%, transparent 72%);
|
||||||
|
background-size: 300% 300%, 250% 250%;
|
||||||
|
animation: qpc2Sea 12s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes qpc2Sea {
|
||||||
|
0% { background-position: 0% 0%, 100% 0%; }
|
||||||
|
100% { background-position: 100% 100%, 0% 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faint gold orb top-right */
|
||||||
|
.qpc2-orb {
|
||||||
|
position: absolute; top: -40px; right: -30px;
|
||||||
|
width: 160px; height: 160px; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(251,191,36,0.14) 0%, transparent 70%);
|
||||||
|
pointer-events: none; z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ RANK HERO (always visible) ══ */
|
||||||
|
.qpc2-hero {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
padding: 1rem 1.1rem 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s ease;
|
||||||
|
}
|
||||||
|
.qpc2-hero:hover { background: rgba(255,255,255,0.025); }
|
||||||
|
|
||||||
|
.qpc2-hero-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.qpc2-hero-left { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
|
||||||
|
.qpc2-hero-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Rank badge icon */
|
||||||
|
.qpc2-rank-icon {
|
||||||
|
width: 44px; height: 44px; border-radius: 14px; flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, #1e0e4a, #3730a3);
|
||||||
|
border: 1.5px solid rgba(251,191,36,0.35);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
box-shadow: 0 4px 0 rgba(30,14,74,0.7), 0 0 16px rgba(251,191,36,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qpc2-rank-label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.78rem; font-weight: 700;
|
||||||
|
color: rgba(255,255,255,0.45); letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
.qpc2-rank-name {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 1.05rem; font-weight: 700;
|
||||||
|
color: #fbbf24;
|
||||||
|
text-shadow: 0 0 18px rgba(251,191,36,0.45);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rank progress bar */
|
||||||
|
.qpc2-rank-bar-wrap {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.qpc2-rank-bar-track {
|
||||||
|
flex: 1; height: 5px; border-radius: 100px;
|
||||||
|
background: rgba(255,255,255,0.1); overflow: hidden;
|
||||||
|
}
|
||||||
|
.qpc2-rank-bar-fill {
|
||||||
|
height: 100%; border-radius: 100px;
|
||||||
|
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
||||||
|
box-shadow: 0 0 8px rgba(251,191,36,0.5);
|
||||||
|
transition: width 0.7s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.qpc2-rank-bar-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.6rem; font-weight: 700;
|
||||||
|
color: rgba(255,255,255,0.35); white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats row */
|
||||||
|
.qpc2-stats {
|
||||||
|
display: flex; gap: 0.5rem; margin-top: 0.75rem;
|
||||||
|
padding-top: 0.7rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.07);
|
||||||
|
}
|
||||||
|
.qpc2-stat {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.qpc2-stat-val {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.95rem; font-weight: 900; color: #fbbf24;
|
||||||
|
}
|
||||||
|
.qpc2-stat-lbl {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.56rem; font-weight: 700;
|
||||||
|
color: rgba(255,255,255,0.35); text-align: center;
|
||||||
|
letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.qpc2-stat-div {
|
||||||
|
width: 1px; background: rgba(255,255,255,0.08); margin: 0.1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chest badge */
|
||||||
|
.qpc2-chest-badge {
|
||||||
|
display: flex; align-items: center; gap: 0.22rem;
|
||||||
|
padding: 0.22rem 0.6rem;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.65rem; font-weight: 900; color: #1a0800;
|
||||||
|
box-shadow: 0 2px 0 #d97706, 0 0 10px rgba(251,191,36,0.35);
|
||||||
|
animation: qpc2ChestPop 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes qpc2ChestPop {
|
||||||
|
0%,100%{ transform: scale(1); }
|
||||||
|
50% { transform: scale(1.07); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand chevron */
|
||||||
|
.qpc2-chevron {
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s;
|
||||||
|
}
|
||||||
|
.qpc2-chevron.open { transform: rotate(180deg); color: #fbbf24; }
|
||||||
|
|
||||||
|
/* ══ COLLAPSIBLE BODY ══ */
|
||||||
|
.qpc2-body {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
transition: max-height 0.4s cubic-bezier(0.4,0,0.2,1);
|
||||||
|
}
|
||||||
|
.qpc2-body.open { max-height: 600px; }
|
||||||
|
|
||||||
|
.qpc2-divider {
|
||||||
|
height: 1px; background: rgba(255,255,255,0.07); margin: 0 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ QUEST ROWS ══ */
|
||||||
|
.qpc2-quest-list { display: flex; flex-direction: column; padding: 0.5rem 0; }
|
||||||
|
|
||||||
|
.qpc2-quest-row {
|
||||||
|
display: flex; align-items: center; gap: 0.7rem;
|
||||||
|
padding: 0.75rem 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.qpc2-quest-row:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
|
/* Left accent line = arc colour */
|
||||||
|
.qpc2-quest-row::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 16%; bottom: 16%;
|
||||||
|
width: 3px; border-radius: 0 3px 3px 0;
|
||||||
|
background: var(--ac);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qpc2-quest-icon {
|
||||||
|
width: 38px; height: 38px; border-radius: 12px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1.5px solid rgba(255,255,255,0.08);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.qpc2-quest-row:hover .qpc2-quest-icon { transform: scale(1.1) rotate(-5deg); }
|
||||||
|
.qpc2-quest-icon.claimable {
|
||||||
|
background: rgba(251,191,36,0.12);
|
||||||
|
border-color: rgba(251,191,36,0.4);
|
||||||
|
animation: qpc2Wiggle 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes qpc2Wiggle {
|
||||||
|
0%,100%{ transform: rotate(0deg); }
|
||||||
|
25% { transform: rotate(-8deg) scale(1.06); }
|
||||||
|
75% { transform: rotate(8deg) scale(1.06); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qpc2-quest-body { flex: 1; min-width: 0; }
|
||||||
|
.qpc2-quest-arc {
|
||||||
|
font-size: 0.57rem; font-weight: 800; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--ac);
|
||||||
|
margin-bottom: 0.08rem;
|
||||||
|
}
|
||||||
|
.qpc2-quest-title {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.9);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
margin-bottom: 0.28rem;
|
||||||
|
}
|
||||||
|
.qpc2-mini-track {
|
||||||
|
height: 4px; background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 100px; overflow: hidden; margin-bottom: 0.18rem;
|
||||||
|
}
|
||||||
|
.qpc2-mini-fill {
|
||||||
|
height: 100%; border-radius: 100px;
|
||||||
|
background: var(--ac);
|
||||||
|
box-shadow: 0 0 5px color-mix(in srgb, var(--ac) 55%, transparent);
|
||||||
|
transition: width 0.5s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.qpc2-mini-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.58rem; font-weight: 700; color: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
.qpc2-claimable-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.62rem; font-weight: 700; color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Claim button */
|
||||||
|
.qpc2-claim-btn {
|
||||||
|
padding: 0.32rem 0.7rem; border: none; border-radius: 100px; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.65rem; font-weight: 900; color: #1a0800;
|
||||||
|
box-shadow: 0 2px 0 #d97706, 0 3px 8px rgba(251,191,36,0.25);
|
||||||
|
flex-shrink: 0; white-space: nowrap;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
}
|
||||||
|
.qpc2-claim-btn:hover { transform: translateY(-1px); box-shadow: 0 3px 0 #d97706; }
|
||||||
|
.qpc2-claim-btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
/* ══ FOOTER LINK ══ */
|
||||||
|
.qpc2-footer {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
||||||
|
padding: 0.65rem 1.1rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.07);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.qpc2-footer:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.qpc2-footer-label {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.72rem; font-weight: 800;
|
||||||
|
color: rgba(251,191,36,0.7);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.qpc2-footer:hover .qpc2-footer-label { color: #fbbf24; }
|
||||||
|
|
||||||
|
/* ══ EMPTY STATE ══ */
|
||||||
|
.qpc2-empty {
|
||||||
|
padding: 1.25rem 1.1rem; text-align: center;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.qpc2-empty-title {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 0.88rem; font-weight: 700; color: rgba(255,255,255,0.55);
|
||||||
|
}
|
||||||
|
.qpc2-empty-sub {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.68rem; font-weight: 600; color: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function getActiveQuests(arcs: QuestArc[]) {
|
||||||
|
const results: { node: QuestNode; arc: QuestArc }[] = [];
|
||||||
|
for (const arc of arcs) {
|
||||||
|
for (const node of arc.nodes) {
|
||||||
|
if (node.status === "claimable" || node.status === "active") {
|
||||||
|
results.push({ node, arc });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Claimable first, then active; max 2 shown
|
||||||
|
results.sort((a, b) => {
|
||||||
|
if (a.node.status === "claimable" && b.node.status !== "claimable")
|
||||||
|
return -1;
|
||||||
|
if (b.node.status === "claimable" && a.node.status !== "claimable")
|
||||||
|
return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return results.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
interface Props {
|
||||||
|
onViewAll?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuestProgressCard = ({ onViewAll }: Props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const arcs = useQuestStore((s) => s.arcs);
|
||||||
|
const claimNode = useQuestStore((s) => s.claimNode);
|
||||||
|
|
||||||
|
const summary = getQuestSummary(arcs);
|
||||||
|
const rank = getCrewRank(arcs);
|
||||||
|
const activeQuests = getActiveQuests(arcs);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [claimingNode, setClaimingNode] = useState<{
|
||||||
|
node: QuestNode;
|
||||||
|
arcId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleViewAll = () => {
|
||||||
|
if (onViewAll) onViewAll();
|
||||||
|
else navigate("/student/quests");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClaim = (node: QuestNode, arcId: string) => {
|
||||||
|
setClaimingNode({ node, arcId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChestClose = () => {
|
||||||
|
if (!claimingNode) return;
|
||||||
|
claimNode(claimingNode.arcId, claimingNode.node.id);
|
||||||
|
setClaimingNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Next rank label
|
||||||
|
const nextRankLabel = rank.next
|
||||||
|
? `${Math.round(rank.progressToNext * 100)}% to ${rank.next.label}`
|
||||||
|
: "Max rank reached";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
|
<div className="qpc2-card">
|
||||||
|
{/* Atmosphere layers */}
|
||||||
|
<div className="qpc2-sea" />
|
||||||
|
<div className="qpc2-orb" />
|
||||||
|
|
||||||
|
{/* ── Rank hero (always visible, tap to expand) ── */}
|
||||||
|
<div className="qpc2-hero" onClick={() => setOpen((o) => !o)}>
|
||||||
|
<div className="qpc2-hero-row">
|
||||||
|
<div className="qpc2-hero-left">
|
||||||
|
<div className="qpc2-rank-icon">{rank.emoji}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p className="qpc2-rank-label">Crew Rank</p>
|
||||||
|
<p className="qpc2-rank-name">{rank.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="qpc2-hero-right">
|
||||||
|
{summary.claimableNodes > 0 && (
|
||||||
|
<div className="qpc2-chest-badge">
|
||||||
|
📦 {summary.claimableNodes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={`qpc2-chevron${open ? " open" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rank progress bar */}
|
||||||
|
<div className="qpc2-rank-bar-wrap">
|
||||||
|
<div className="qpc2-rank-bar-track">
|
||||||
|
<div
|
||||||
|
className="qpc2-rank-bar-fill"
|
||||||
|
style={{ width: `${Math.round(rank.progressToNext * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="qpc2-rank-bar-label">{nextRankLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats strip */}
|
||||||
|
<div className="qpc2-stats">
|
||||||
|
{[
|
||||||
|
{ val: `${summary.earnedXP}`, lbl: "XP Earned" },
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
val: `${summary.completedNodes}/${summary.totalNodes}`,
|
||||||
|
lbl: "Quests Done",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
val: `${summary.arcsCompleted}/${summary.totalArcs}`,
|
||||||
|
lbl: "Arcs",
|
||||||
|
},
|
||||||
|
].map((item, i) =>
|
||||||
|
item === null ? (
|
||||||
|
<div key={i} className="qpc2-stat-div" />
|
||||||
|
) : (
|
||||||
|
<div key={i} className="qpc2-stat">
|
||||||
|
<span className="qpc2-stat-val">{item.val}</span>
|
||||||
|
<span className="qpc2-stat-lbl">{item.lbl}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Collapsible quest list ── */}
|
||||||
|
<div className={`qpc2-body${open ? " open" : ""}`}>
|
||||||
|
<div className="qpc2-divider" />
|
||||||
|
<div className="qpc2-quest-list">
|
||||||
|
{activeQuests.length === 0 ? (
|
||||||
|
<div className="qpc2-empty">
|
||||||
|
<span style={{ fontSize: "1.75rem" }}>⚓</span>
|
||||||
|
<p className="qpc2-empty-title">All caught up, Captain!</p>
|
||||||
|
<p className="qpc2-empty-sub">
|
||||||
|
No active quests — keep sailing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeQuests.map(({ node, arc }) => {
|
||||||
|
const pct = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((node.progress / node.requirement.target) * 100),
|
||||||
|
);
|
||||||
|
const isClaimable = node.status === "claimable";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className="qpc2-quest-row"
|
||||||
|
style={{ "--ac": arc.accentColor } as React.CSSProperties}
|
||||||
|
onClick={() => !isClaimable && handleViewAll()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`qpc2-quest-icon${isClaimable ? " claimable" : ""}`}
|
||||||
|
>
|
||||||
|
{isClaimable ? "📦" : node.emoji}
|
||||||
|
</div>
|
||||||
|
<div className="qpc2-quest-body">
|
||||||
|
<p className="qpc2-quest-arc">
|
||||||
|
{arc.emoji} {arc.name}
|
||||||
|
</p>
|
||||||
|
<p className="qpc2-quest-title">{node.title}</p>
|
||||||
|
{isClaimable ? (
|
||||||
|
<p className="qpc2-claimable-label">
|
||||||
|
✨ Chest ready to open!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="qpc2-mini-track">
|
||||||
|
<div
|
||||||
|
className="qpc2-mini-fill"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="qpc2-mini-label">
|
||||||
|
{node.progress} / {node.requirement.target}{" "}
|
||||||
|
{node.requirement.label}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isClaimable ? (
|
||||||
|
<button
|
||||||
|
className="qpc2-claim-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClaim(node, arc.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open 📦
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} color="rgba(255,255,255,0.2)" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — navigate to full map */}
|
||||||
|
<div className="qpc2-footer" onClick={handleViewAll}>
|
||||||
|
<span className="qpc2-footer-label">View full quest map</span>
|
||||||
|
<ChevronRight size={14} color="rgba(251,191,36,0.7)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claimingNode && (
|
||||||
|
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,18 @@
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Search, X } from "lucide-react";
|
import {
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
BookOpen,
|
||||||
|
Zap,
|
||||||
|
Target,
|
||||||
|
Trophy,
|
||||||
|
User,
|
||||||
|
Home,
|
||||||
|
ArrowRight,
|
||||||
|
Clock,
|
||||||
|
Flame,
|
||||||
|
} from "lucide-react";
|
||||||
import type { PracticeSheet } from "../types/sheet";
|
import type { PracticeSheet } from "../types/sheet";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { SearchItem } from "../types/search";
|
import type { SearchItem } from "../types/search";
|
||||||
@ -13,20 +25,32 @@ interface Props {
|
|||||||
setSearchQuery: (value: string) => void;
|
setSearchQuery: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationItems: SearchItem[] = [
|
// ─── Nav items ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NAV_ITEMS: (SearchItem & {
|
||||||
|
icon: React.ElementType;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
})[] = [
|
||||||
{
|
{
|
||||||
type: "route",
|
type: "route",
|
||||||
title: "Hard Test Modules",
|
title: "Hard Test Modules",
|
||||||
description: "Access advanced SAT modules",
|
description: "Tackle the hardest SAT questions",
|
||||||
route: "/student/hard-test-modules",
|
route: "/student/hard-test-modules",
|
||||||
group: "Pages",
|
group: "Pages",
|
||||||
|
icon: Trophy,
|
||||||
|
color: "#84cc16",
|
||||||
|
bg: "#f7ffe4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "route",
|
type: "route",
|
||||||
title: "Targeted Practice",
|
title: "Targeted Practice",
|
||||||
description: "Focus on what matters",
|
description: "Focus on your weak spots",
|
||||||
route: "/student/practice/targeted-practice",
|
route: "/student/practice/targeted-practice",
|
||||||
group: "Pages",
|
group: "Pages",
|
||||||
|
icon: Target,
|
||||||
|
color: "#ef4444",
|
||||||
|
bg: "#fff5f5",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "route",
|
type: "route",
|
||||||
@ -34,64 +58,291 @@ const navigationItems: SearchItem[] = [
|
|||||||
description: "Train speed and accuracy",
|
description: "Train speed and accuracy",
|
||||||
route: "/student/practice/drills",
|
route: "/student/practice/drills",
|
||||||
group: "Pages",
|
group: "Pages",
|
||||||
|
icon: Zap,
|
||||||
|
color: "#0891b2",
|
||||||
|
bg: "#ecfeff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "route",
|
type: "route",
|
||||||
title: "Leaderboard",
|
title: "Leaderboard",
|
||||||
description: "View student rankings",
|
description: "See how you rank against others",
|
||||||
route: "/student/rewards",
|
route: "/student/rewards",
|
||||||
group: "Pages",
|
group: "Pages",
|
||||||
|
icon: Trophy,
|
||||||
|
color: "#f97316",
|
||||||
|
bg: "#fff7ed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "route",
|
type: "route",
|
||||||
title: "Practice",
|
title: "Practice",
|
||||||
description: "See how you can practice",
|
description: "Browse all practice modes",
|
||||||
route: "/student/practice",
|
route: "/student/practice",
|
||||||
group: "Pages",
|
group: "Pages",
|
||||||
|
icon: BookOpen,
|
||||||
|
color: "#a855f7",
|
||||||
|
bg: "#fdf4ff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "route",
|
type: "route",
|
||||||
title: "Lessons",
|
title: "Lessons",
|
||||||
description: "Watch detailed lessons on SAT techniques",
|
description: "Watch expert SAT technique lessons",
|
||||||
route: "/student/lessons",
|
route: "/student/lessons",
|
||||||
group: "Pages",
|
group: "Pages",
|
||||||
|
icon: BookOpen,
|
||||||
|
color: "#0891b2",
|
||||||
|
bg: "#ecfeff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "route",
|
type: "route",
|
||||||
title: "Profile",
|
title: "Profile",
|
||||||
description: "View your profile",
|
description: "View your profile and settings",
|
||||||
route: "/student/profile",
|
route: "/student/profile",
|
||||||
group: "Pages",
|
group: "Pages",
|
||||||
|
icon: User,
|
||||||
|
color: "#e11d48",
|
||||||
|
bg: "#fff1f2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "route",
|
||||||
|
title: "Home",
|
||||||
|
description: "Go back to home",
|
||||||
|
route: "/student/home",
|
||||||
|
group: "Pages",
|
||||||
|
icon: Home,
|
||||||
|
color: "#f97316",
|
||||||
|
bg: "#fff7ed",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const NAV_MAP = Object.fromEntries(NAV_ITEMS.map((n) => [n.route, n]));
|
||||||
|
|
||||||
|
const STATUS_META = {
|
||||||
|
IN_PROGRESS: {
|
||||||
|
label: "In Progress",
|
||||||
|
color: "#9333ea",
|
||||||
|
bg: "#f3e8ff",
|
||||||
|
icon: "🔄",
|
||||||
|
},
|
||||||
|
COMPLETED: {
|
||||||
|
label: "Completed",
|
||||||
|
color: "#16a34a",
|
||||||
|
bg: "#f0fdf4",
|
||||||
|
icon: "✅",
|
||||||
|
},
|
||||||
|
NOT_STARTED: {
|
||||||
|
label: "Not Started",
|
||||||
|
color: "#6b7280",
|
||||||
|
bg: "#f3f4f6",
|
||||||
|
icon: "📋",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Recent items (session memory) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const SESSION_KEY = "so_recent";
|
||||||
|
const MAX_RECENT = 5;
|
||||||
|
|
||||||
|
const getRecent = (): SearchItem[] => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(sessionStorage.getItem(SESSION_KEY) ?? "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addRecent = (item: SearchItem) => {
|
||||||
|
const prev = getRecent().filter((r) => r.route !== item.route);
|
||||||
|
const next = [item, ...prev].slice(0, MAX_RECENT);
|
||||||
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Highlight helper ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const highlightText = (text: string, query: string) => {
|
const highlightText = (text: string, query: string) => {
|
||||||
if (!query.trim()) return text;
|
if (!query.trim()) return <>{text}</>;
|
||||||
|
const esc = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const regex = new RegExp(`(${esc})`, "gi");
|
||||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
|
||||||
|
|
||||||
const parts = text.split(regex);
|
const parts = text.split(regex);
|
||||||
|
return (
|
||||||
return parts.map((part, index) => {
|
<>
|
||||||
const isMatch = part.toLowerCase() === query.toLowerCase();
|
{parts.map((part, i) =>
|
||||||
|
part.toLowerCase() === query.toLowerCase() ? (
|
||||||
return isMatch ? (
|
<mark
|
||||||
<motion.span
|
key={i}
|
||||||
key={index}
|
style={{
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
background: "#e9d5ff",
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
color: "#6b21a8",
|
||||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
borderRadius: 4,
|
||||||
className="bg-purple-200 text-purple-900 px-1 rounded-md"
|
padding: "0 2px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{part}
|
{part}
|
||||||
</motion.span>
|
</mark>
|
||||||
) : (
|
) : (
|
||||||
part
|
part
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
.so-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 50;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; padding-top: 5rem;
|
||||||
|
padding-left: 1rem; padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.so-box {
|
||||||
|
width: 100%; max-width: 560px;
|
||||||
|
background: #fffbf4;
|
||||||
|
border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.18), 0 6px 16px rgba(0,0,0,0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
max-height: calc(100vh - 6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input row */
|
||||||
|
.so-input-row {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 2px solid #f3f4f6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.so-input {
|
||||||
|
flex: 1; outline: none; border: none; background: transparent;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.95rem; font-weight: 800; color: #1e1b4b;
|
||||||
|
}
|
||||||
|
.so-input::placeholder { color: #d1d5db; font-weight: 700; }
|
||||||
|
.so-close-btn {
|
||||||
|
width: 30px; height: 30px; border-radius: 50%; border: 2.5px solid #f3f4f6;
|
||||||
|
background: white; cursor: pointer; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.so-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
|
||||||
|
|
||||||
|
/* Scrollable results */
|
||||||
|
.so-results {
|
||||||
|
overflow-y: auto; flex: 1;
|
||||||
|
padding: 0.75rem 0.75rem 1rem;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
display: flex; flex-direction: column; gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section label */
|
||||||
|
.so-section-label {
|
||||||
|
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase; color: #9ca3af;
|
||||||
|
padding: 0 0.5rem; margin-bottom: -0.35rem;
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result rows */
|
||||||
|
.so-item {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
|
padding: 0.7rem 0.75rem; border-radius: 16px; cursor: pointer;
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.so-item:hover {
|
||||||
|
background: white; border-color: #f3f4f6;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
.so-item:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
.so-item-icon {
|
||||||
|
width: 36px; height: 36px; border-radius: 11px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.so-item-body { flex: 1; min-width: 0; }
|
||||||
|
.so-item-title {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.88rem; font-weight: 900; color: #1e1b4b;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.so-item-desc {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.72rem; font-weight: 600; color: #9ca3af;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.so-item-arrow { color: #d1d5db; flex-shrink: 0; transition: color 0.15s ease; }
|
||||||
|
.so-item:hover .so-item-arrow { color: #a855f7; }
|
||||||
|
|
||||||
|
/* Sheet status chip inline */
|
||||||
|
.so-status-chip {
|
||||||
|
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; border-radius: 100px; padding: 0.15rem 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick nav chips (shown when empty query) */
|
||||||
|
.so-quick-wrap {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
.so-quick-chip {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
background: white; border: 2.5px solid #f3f4f6; border-radius: 100px;
|
||||||
|
padding: 0.45rem 0.85rem; cursor: pointer;
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.75rem; font-weight: 800;
|
||||||
|
color: #6b7280;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.so-quick-chip:hover { transform: translateY(-2px); box-shadow: 0 6px 14px rgba(0,0,0,0.07); border-color: #e9d5ff; color: #a855f7; }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.so-empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
padding: 2rem 1rem; gap: 0.5rem;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
}
|
||||||
|
.so-empty-emoji { font-size: 2rem; }
|
||||||
|
.so-empty-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
|
||||||
|
.so-empty-sub { font-size: 0.75rem; font-weight: 600; color: #d1d5db; text-align: center; }
|
||||||
|
|
||||||
|
/* Keyboard hint */
|
||||||
|
.so-kbd-row {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 1rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-top: 2px solid #f9fafb;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.so-kbd-hint {
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.62rem; font-weight: 600; color: #d1d5db;
|
||||||
|
}
|
||||||
|
.so-kbd {
|
||||||
|
background: white; border: 1.5px solid #e5e7eb; border-radius: 5px;
|
||||||
|
padding: 0.1rem 0.4rem; font-size: 0.6rem; font-weight: 800;
|
||||||
|
color: #9ca3af; box-shadow: 0 1px 0 #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight count badge */
|
||||||
|
.so-count-badge {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.65rem; font-weight: 800;
|
||||||
|
background: #f3e8ff; color: #9333ea;
|
||||||
|
border-radius: 100px; padding: 0.15rem 0.5rem; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const SearchOverlay = ({
|
export const SearchOverlay = ({
|
||||||
sheets,
|
sheets,
|
||||||
onClose,
|
onClose,
|
||||||
@ -99,132 +350,331 @@ export const SearchOverlay = ({
|
|||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [recent, setRecent] = useState<SearchItem[]>(getRecent);
|
||||||
|
const [focused, setFocused] = useState(-1); // keyboard nav index
|
||||||
|
|
||||||
|
// Build full search item list
|
||||||
const searchItems = useMemo<SearchItem[]>(() => {
|
const searchItems = useMemo<SearchItem[]>(() => {
|
||||||
const sheetItems = sheets.map((sheet) => ({
|
const sheetItems = sheets.map((sheet) => ({
|
||||||
type: "sheet",
|
type: "sheet" as const,
|
||||||
id: sheet.id,
|
id: sheet.id,
|
||||||
title: sheet.title,
|
title: sheet.title,
|
||||||
description: sheet.description,
|
description: sheet.description ?? "Practice sheet",
|
||||||
route: `/student/practice/${sheet.id}`,
|
route: `/student/practice/${sheet.id}`,
|
||||||
group: formatGroupTitle(sheet.user_status), // 👈 reuse your grouping
|
group: formatGroupTitle(sheet.user_status),
|
||||||
|
status: sheet.user_status,
|
||||||
}));
|
}));
|
||||||
|
return [...NAV_ITEMS, ...sheetItems];
|
||||||
return [...navigationItems, ...sheetItems];
|
|
||||||
}, [sheets]);
|
}, [sheets]);
|
||||||
|
|
||||||
// Close on ESC
|
// Filtered + grouped results
|
||||||
useEffect(() => {
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKey);
|
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const groupedResults = useMemo(() => {
|
const groupedResults = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return {};
|
if (!searchQuery.trim()) return {};
|
||||||
|
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
|
const filtered = searchItems.filter(
|
||||||
const filtered = searchItems.filter((item) => {
|
(item) =>
|
||||||
const title = item.title?.toLowerCase() || "";
|
item.title?.toLowerCase().includes(q) ||
|
||||||
const description = item.description?.toLowerCase() || "";
|
item.description?.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
return title.includes(q) || description.includes(q);
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => {
|
return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => {
|
||||||
if (!acc[item.group]) {
|
(acc[item.group] ??= []).push(item);
|
||||||
acc[item.group] = [];
|
|
||||||
}
|
|
||||||
acc[item.group].push(item);
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}, [searchQuery, searchItems]);
|
}, [searchQuery, searchItems]);
|
||||||
|
|
||||||
|
const flatResults = useMemo(
|
||||||
|
() => Object.values(groupedResults).flat(),
|
||||||
|
[groupedResults],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ESC to close, arrow keys + enter for keyboard nav
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setFocused((f) => Math.min(f + 1, flatResults.length - 1));
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setFocused((f) => Math.max(f - 1, 0));
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && focused >= 0 && flatResults[focused]) {
|
||||||
|
handleSelect(flatResults[focused]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handle);
|
||||||
|
return () => window.removeEventListener("keydown", handle);
|
||||||
|
}, [onClose, focused, flatResults]);
|
||||||
|
|
||||||
|
// Reset focused when query changes
|
||||||
|
useEffect(() => {
|
||||||
|
setFocused(-1);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleSelect = (item: SearchItem) => {
|
||||||
|
addRecent(item);
|
||||||
|
setRecent(getRecent());
|
||||||
|
onClose();
|
||||||
|
navigate(item.route!);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCount = flatResults.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex flex-col items-center pt-24 px-4"
|
className="so-overlay"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{/* Search Box */}
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: -40, opacity: 0 }}
|
className="so-box"
|
||||||
animate={{ y: 0, opacity: 1 }}
|
initial={{ y: -24, opacity: 0, scale: 0.97 }}
|
||||||
exit={{ y: -40, opacity: 0 }}
|
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||||
transition={{ type: "spring", stiffness: 300 }}
|
exit={{ y: -24, opacity: 0, scale: 0.97 }}
|
||||||
|
transition={{ type: "spring", stiffness: 380, damping: 28 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="w-full max-w-2xl bg-white rounded-3xl shadow-2xl p-6"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
{/* Input row */}
|
||||||
<Search size={20} />
|
<div className="so-input-row">
|
||||||
|
<Search size={18} color="#9ca3af" />
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
className="so-input"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search..."
|
placeholder="Search sheets, pages, topics..."
|
||||||
className="flex-1 outline-none font-satoshi text-lg"
|
|
||||||
/>
|
/>
|
||||||
<button onClick={onClose}>
|
{searchQuery && totalCount > 0 && (
|
||||||
<X size={20} />
|
<span className="so-count-badge">
|
||||||
|
{totalCount} result{totalCount !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="so-close-btn" onClick={onClose}>
|
||||||
|
<X size={13} color="#9ca3af" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div className="mt-6 max-h-96 overflow-y-auto space-y-6">
|
<div className="so-results">
|
||||||
{/* {!searchQuery && (
|
{/* ── Empty query: recent + quick nav ── */}
|
||||||
<p className="font-satoshi text-gray-500">
|
{!searchQuery && (
|
||||||
Start typing to search...
|
<>
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="so-section-label">
|
||||||
|
<Clock size={10} /> Recent
|
||||||
</p>
|
</p>
|
||||||
)} */}
|
{recent.map((item, i) => {
|
||||||
|
const navMeta = NAV_MAP[item.route!];
|
||||||
{searchQuery.length === 0 ? (
|
const Icon = navMeta?.icon ?? BookOpen;
|
||||||
<p className="text-gray-400 font-satoshi">
|
const color = navMeta?.color ?? "#a855f7";
|
||||||
Start typing to search...
|
const bg = navMeta?.bg ?? "#fdf4ff";
|
||||||
</p>
|
return (
|
||||||
) : Object.keys(groupedResults).length === 0 ? (
|
|
||||||
<p className="text-gray-400 font-satoshi">No results found.</p>
|
|
||||||
) : (
|
|
||||||
Object.entries(groupedResults).map(([group, items]) => (
|
|
||||||
<div key={group}>
|
|
||||||
<p className="text-xs uppercase tracking-wider text-gray-400 font-satoshi mb-3">
|
|
||||||
{group}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={i}
|
||||||
onClick={() => {
|
className="so-item"
|
||||||
onClose();
|
onClick={() => handleSelect(item)}
|
||||||
navigate(item.route!);
|
|
||||||
}}
|
|
||||||
className="p-4 rounded-2xl hover:bg-gray-100 cursor-pointer transition"
|
|
||||||
>
|
>
|
||||||
<p className="font-satoshi-medium">
|
<div
|
||||||
{highlightText(item.title, searchQuery)}
|
className="so-item-icon"
|
||||||
</p>
|
style={{ background: bg }}
|
||||||
|
>
|
||||||
|
<Icon size={16} color={color} />
|
||||||
|
</div>
|
||||||
|
<div className="so-item-body">
|
||||||
|
<p className="so-item-title">{item.title}</p>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p className="text-sm text-gray-500">
|
<p className="so-item-desc">{item.description}</p>
|
||||||
{highlightText(item.description, searchQuery)}
|
)}
|
||||||
</p>
|
</div>
|
||||||
|
<ArrowRight size={15} className="so-item-arrow" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-purple-500 mt-1">
|
<div>
|
||||||
{item.type === "route" ? "" : "Practice Sheet"}
|
<p className="so-section-label">⚡ Quick nav</p>
|
||||||
</p>
|
<div
|
||||||
</div>
|
className="so-quick-wrap"
|
||||||
|
style={{ marginTop: "0.5rem" }}
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="so-quick-chip"
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
<item.icon size={13} color={item.color} />
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
|
{sheets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="so-section-label">
|
||||||
|
<Flame size={10} /> In progress
|
||||||
|
</p>
|
||||||
|
{sheets
|
||||||
|
.filter((s) => s.user_status === "IN_PROGRESS")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((sheet) => {
|
||||||
|
const item: SearchItem = {
|
||||||
|
type: "sheet",
|
||||||
|
title: sheet.title,
|
||||||
|
description: sheet.description,
|
||||||
|
route: `/student/practice/${sheet.id}`,
|
||||||
|
group: "In Progress",
|
||||||
|
status: sheet.user_status,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sheet.id}
|
||||||
|
className="so-item"
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="so-item-icon"
|
||||||
|
style={{ background: "#f3e8ff" }}
|
||||||
|
>
|
||||||
|
<BookOpen size={16} color="#a855f7" />
|
||||||
|
</div>
|
||||||
|
<div className="so-item-body">
|
||||||
|
<p className="so-item-title">{sheet.title}</p>
|
||||||
|
{sheet.description && (
|
||||||
|
<p className="so-item-desc">
|
||||||
|
{sheet.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
className="so-status-chip"
|
||||||
|
style={{
|
||||||
|
background: "#f3e8ff",
|
||||||
|
color: "#9333ea",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
In Progress
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── No results ── */}
|
||||||
|
{searchQuery && totalCount === 0 && (
|
||||||
|
<div className="so-empty">
|
||||||
|
<span className="so-empty-emoji">🔍</span>
|
||||||
|
<p className="so-empty-text">No results for "{searchQuery}"</p>
|
||||||
|
<p className="so-empty-sub">
|
||||||
|
Try searching for a topic, sheet title, or page name
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Results grouped ── */}
|
||||||
|
{searchQuery &&
|
||||||
|
totalCount > 0 &&
|
||||||
|
Object.entries(groupedResults).map(([group, items]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<p className="so-section-label">{group}</p>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const globalIdx = flatResults.indexOf(item);
|
||||||
|
const isFocused = globalIdx === focused;
|
||||||
|
const navMeta = NAV_MAP[item.route!];
|
||||||
|
const Icon = navMeta?.icon ?? BookOpen;
|
||||||
|
const iconColor = navMeta?.color ?? "#a855f7";
|
||||||
|
const iconBg = navMeta?.bg ?? "#fdf4ff";
|
||||||
|
const statusMeta = item.status
|
||||||
|
? STATUS_META[item.status as keyof typeof STATUS_META]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="so-item"
|
||||||
|
style={{
|
||||||
|
background: isFocused ? "white" : undefined,
|
||||||
|
borderColor: isFocused ? "#e9d5ff" : undefined,
|
||||||
|
boxShadow: isFocused
|
||||||
|
? "0 4px 12px rgba(0,0,0,0.06)"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.03 }}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="so-item-icon"
|
||||||
|
style={{ background: iconBg }}
|
||||||
|
>
|
||||||
|
{item.type === "sheet" ? (
|
||||||
|
<span style={{ fontSize: "1rem" }}>
|
||||||
|
{statusMeta?.icon ?? "📋"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Icon size={16} color={iconColor} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="so-item-body">
|
||||||
|
<p className="so-item-title">
|
||||||
|
{highlightText(item.title, searchQuery)}
|
||||||
|
</p>
|
||||||
|
{item.description && (
|
||||||
|
<p className="so-item-desc">
|
||||||
|
{highlightText(item.description, searchQuery)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{statusMeta && (
|
||||||
|
<span
|
||||||
|
className="so-status-chip"
|
||||||
|
style={{
|
||||||
|
background: statusMeta.bg,
|
||||||
|
color: statusMeta.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusMeta.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ArrowRight size={15} className="so-item-arrow" />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard hints */}
|
||||||
|
<div className="so-kbd-row">
|
||||||
|
<div className="so-kbd-hint">
|
||||||
|
<kbd className="so-kbd">↑↓</kbd> Navigate
|
||||||
|
</div>
|
||||||
|
<div className="so-kbd-hint">
|
||||||
|
<kbd className="so-kbd">↵</kbd> Open
|
||||||
|
</div>
|
||||||
|
<div className="so-kbd-hint">
|
||||||
|
<kbd className="so-kbd">Esc</kbd> Close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
@ -244,7 +244,14 @@ function Sidebar({
|
|||||||
<div
|
<div
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-slot="sidebar-inner"
|
data-slot="sidebar-inner"
|
||||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col",
|
||||||
|
// For the custom floating pill sidebar we render our own card,
|
||||||
|
// so keep this container visually transparent.
|
||||||
|
variant === "floating"
|
||||||
|
? "bg-transparent"
|
||||||
|
: "bg-sidebar group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
344
src/data/questData.ts
Normal file
344
src/data/questData.ts
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import type { QuestArc } from "../types/quest";
|
||||||
|
|
||||||
|
// ─── QUEST DATA ───────────────────────────────────────────────────────────────
|
||||||
|
// Replace each node's `progress` and `status` with live API values.
|
||||||
|
// Everything else (titles, flavour, rewards) is content — edit freely.
|
||||||
|
|
||||||
|
export const QUEST_ARCS: QuestArc[] = [
|
||||||
|
// ── ARC 1: The Calm Seas ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "east_blue",
|
||||||
|
name: "The Calm Seas",
|
||||||
|
subtitle: "Every great voyage begins at shore",
|
||||||
|
emoji: "🌊",
|
||||||
|
accentColor: "#0ea5e9",
|
||||||
|
accentDark: "#0369a1",
|
||||||
|
bgFrom: "#0c4a6e",
|
||||||
|
bgTo: "#075985",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "eb_1",
|
||||||
|
title: "First Steps",
|
||||||
|
flavourText:
|
||||||
|
'"I\'ll become the greatest sailor who ever lived!" — Every legend begins with a single step.',
|
||||||
|
islandName: "Hawthorn Cove",
|
||||||
|
emoji: "🏝️",
|
||||||
|
requirement: {
|
||||||
|
type: "questions",
|
||||||
|
target: 10,
|
||||||
|
label: "questions answered",
|
||||||
|
},
|
||||||
|
progress: 10,
|
||||||
|
status: "completed",
|
||||||
|
reward: { xp: 50, title: "Cabin Hand" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_2",
|
||||||
|
title: "Cast Off",
|
||||||
|
flavourText:
|
||||||
|
'"The sea doesn\'t care who you were — only who you become." Chart your course.',
|
||||||
|
islandName: "Redmast Port",
|
||||||
|
emoji: "⚓",
|
||||||
|
requirement: {
|
||||||
|
type: "sessions",
|
||||||
|
target: 3,
|
||||||
|
label: "practice sessions",
|
||||||
|
},
|
||||||
|
progress: 3,
|
||||||
|
status: "completed",
|
||||||
|
reward: { xp: 75 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_3",
|
||||||
|
title: "The Tangerine Coast",
|
||||||
|
flavourText:
|
||||||
|
'"Even alone, I protect my crew." Keep your streak burning bright.',
|
||||||
|
islandName: "Citrus Bay",
|
||||||
|
emoji: "🍊",
|
||||||
|
requirement: { type: "streak", target: 3, label: "day streak" },
|
||||||
|
progress: 3,
|
||||||
|
status: "completed",
|
||||||
|
reward: {
|
||||||
|
xp: 100,
|
||||||
|
item: "streak_shield",
|
||||||
|
itemLabel: "Streak Shield ×1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_4",
|
||||||
|
title: "The Fog Village",
|
||||||
|
flavourText:
|
||||||
|
'"I\'ve fooled everyone — except myself." Prove yourself across new territory.',
|
||||||
|
islandName: "Mistholm Village",
|
||||||
|
emoji: "🌿",
|
||||||
|
requirement: { type: "topics", target: 5, label: "topics practiced" },
|
||||||
|
progress: 3,
|
||||||
|
status: "claimable",
|
||||||
|
reward: { xp: 125, title: "Deckhand" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_5",
|
||||||
|
title: "The Floating Galley",
|
||||||
|
flavourText:
|
||||||
|
'"Nothing happened." Cut through the noise with razor accuracy.',
|
||||||
|
islandName: "The Iron Kitchen",
|
||||||
|
emoji: "🍖",
|
||||||
|
requirement: {
|
||||||
|
type: "accuracy",
|
||||||
|
target: 75,
|
||||||
|
label: "% accuracy (any session)",
|
||||||
|
},
|
||||||
|
progress: 58,
|
||||||
|
status: "active",
|
||||||
|
reward: {
|
||||||
|
xp: 150,
|
||||||
|
item: "xp_boost",
|
||||||
|
itemLabel: "2× XP Boost (1 session)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_6",
|
||||||
|
title: "The Sharkfin Strait",
|
||||||
|
flavourText:
|
||||||
|
'"This is my dream!" Conquer the Calm Seas before the Grand Voyage beckons.',
|
||||||
|
islandName: "Sharkfin Strait",
|
||||||
|
emoji: "🦈",
|
||||||
|
requirement: {
|
||||||
|
type: "questions",
|
||||||
|
target: 100,
|
||||||
|
label: "questions answered",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 300, title: "First Mate" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── ARC 2: The Amber Wastes ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "alabasta",
|
||||||
|
name: "The Amber Wastes",
|
||||||
|
subtitle: "Through the desert sands, to glory",
|
||||||
|
emoji: "🏜️",
|
||||||
|
accentColor: "#f59e0b",
|
||||||
|
accentDark: "#b45309",
|
||||||
|
bgFrom: "#78350f",
|
||||||
|
bgTo: "#92400e",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "al_1",
|
||||||
|
title: "Crossing the Mirrorlake",
|
||||||
|
flavourText:
|
||||||
|
'"A true sailor never makes excuses after losing." Enter the warzone.',
|
||||||
|
islandName: "Mirrorlake Basin",
|
||||||
|
emoji: "💧",
|
||||||
|
requirement: {
|
||||||
|
type: "sessions",
|
||||||
|
target: 5,
|
||||||
|
label: "practice sessions",
|
||||||
|
},
|
||||||
|
progress: 5,
|
||||||
|
status: "completed",
|
||||||
|
reward: { xp: 150 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_2",
|
||||||
|
title: "The Sand March",
|
||||||
|
flavourText:
|
||||||
|
'"They underestimated us." Grind through the scorching heat.',
|
||||||
|
islandName: "The Amber Dunes",
|
||||||
|
emoji: "🌵",
|
||||||
|
requirement: {
|
||||||
|
type: "questions",
|
||||||
|
target: 50,
|
||||||
|
label: "questions answered",
|
||||||
|
},
|
||||||
|
progress: 50,
|
||||||
|
status: "completed",
|
||||||
|
reward: {
|
||||||
|
xp: 175,
|
||||||
|
item: "xp_boost",
|
||||||
|
itemLabel: "1.5× XP Boost (1 session)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_3",
|
||||||
|
title: "The Sunstone Palace",
|
||||||
|
flavourText: '"I refuse to let my crew fall!" Climb the leaderboard.',
|
||||||
|
islandName: "Sunstone City",
|
||||||
|
emoji: "🏰",
|
||||||
|
requirement: {
|
||||||
|
type: "leaderboard",
|
||||||
|
target: 10,
|
||||||
|
label: "leaderboard rank",
|
||||||
|
},
|
||||||
|
progress: 22,
|
||||||
|
status: "active",
|
||||||
|
reward: { xp: 250, title: "Corsair" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_4",
|
||||||
|
title: "Blades in the Bazaar",
|
||||||
|
flavourText:
|
||||||
|
'"I\'ll cut through iron." Maintain brutal accuracy under pressure.',
|
||||||
|
islandName: "Bazaar Streets",
|
||||||
|
emoji: "⚔️",
|
||||||
|
requirement: {
|
||||||
|
type: "accuracy",
|
||||||
|
target: 85,
|
||||||
|
label: "% accuracy (any session)",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: {
|
||||||
|
xp: 300,
|
||||||
|
item: "streak_shield",
|
||||||
|
itemLabel: "Streak Shield ×2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_5",
|
||||||
|
title: "The Warlord Falls",
|
||||||
|
flavourText:
|
||||||
|
"\"I'm not dying here, partner.\" Prove you're worthy of the Wastes.",
|
||||||
|
islandName: "The Throne Dune",
|
||||||
|
emoji: "👑",
|
||||||
|
requirement: { type: "streak", target: 7, label: "day streak" },
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 400, title: "Corsair" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_6",
|
||||||
|
title: "The Princess's Farewell",
|
||||||
|
flavourText:
|
||||||
|
'"Even if our paths split, you\'ll always sail with my crew." The arc is complete.',
|
||||||
|
islandName: "Mirrorlake Harbour",
|
||||||
|
emoji: "🌅",
|
||||||
|
requirement: { type: "xp", target: 1000, label: "total XP earned" },
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 500, title: "Sea Emperor" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── ARC 3: The Sky Reaches ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "skypiea",
|
||||||
|
name: "The Sky Reaches",
|
||||||
|
subtitle: "Ascend to the island above the clouds",
|
||||||
|
emoji: "☁️",
|
||||||
|
accentColor: "#a855f7",
|
||||||
|
accentDark: "#7c3aed",
|
||||||
|
bgFrom: "#3b0764",
|
||||||
|
bgTo: "#4c1d95",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "sk_1",
|
||||||
|
title: "The Skyward Torrent",
|
||||||
|
flavourText:
|
||||||
|
'"The sky island is real!" Believe it — launch yourself upward.',
|
||||||
|
islandName: "Upper Cloudreach",
|
||||||
|
emoji: "🌤️",
|
||||||
|
requirement: {
|
||||||
|
type: "topics",
|
||||||
|
target: 3,
|
||||||
|
label: "topics at 70%+ accuracy",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 200 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_2",
|
||||||
|
title: "The Trial of Storms",
|
||||||
|
flavourText:
|
||||||
|
'"Follow the wind, follow the stars." Navigate every corner of the cloudscape.',
|
||||||
|
islandName: "The Tempest Ordeal",
|
||||||
|
emoji: "🎯",
|
||||||
|
requirement: {
|
||||||
|
type: "topics",
|
||||||
|
target: 8,
|
||||||
|
label: "distinct topics practiced",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: {
|
||||||
|
xp: 250,
|
||||||
|
item: "xp_boost",
|
||||||
|
itemLabel: "2× XP Boost (2 sessions)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_3",
|
||||||
|
title: "The Sky God's Wrath",
|
||||||
|
flavourText: '"I am the heavens." Are you good enough to defy a deity?',
|
||||||
|
islandName: "The Celestial Ark",
|
||||||
|
emoji: "⚡",
|
||||||
|
requirement: {
|
||||||
|
type: "accuracy",
|
||||||
|
target: 90,
|
||||||
|
label: "% accuracy (any session)",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 400, title: "Sea Emperor" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_4",
|
||||||
|
title: "The Ancient Bell",
|
||||||
|
flavourText:
|
||||||
|
'"I hear the torrent calling." Ring the bell — make history echo.',
|
||||||
|
islandName: "The Cloudvine Spire",
|
||||||
|
emoji: "🔔",
|
||||||
|
requirement: {
|
||||||
|
type: "questions",
|
||||||
|
target: 250,
|
||||||
|
label: "questions answered",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: {
|
||||||
|
xp: 500,
|
||||||
|
item: "streak_shield",
|
||||||
|
itemLabel: "Streak Shield ×3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_5",
|
||||||
|
title: "The Gilded Ruins",
|
||||||
|
flavourText:
|
||||||
|
'"THE GREAT CAPTAIN WAS HERE." Touch the treasure that all legends sought.',
|
||||||
|
islandName: "Aureveil",
|
||||||
|
emoji: "💰",
|
||||||
|
requirement: { type: "xp", target: 3000, label: "total XP earned" },
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 750, title: "Grand Captain" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_6",
|
||||||
|
title: "The Grand Captain",
|
||||||
|
flavourText:
|
||||||
|
'"This is my treasure!" You\'ve reached the summit — your target score awaits.',
|
||||||
|
islandName: "The Last Isle",
|
||||||
|
emoji: "🏴☠️",
|
||||||
|
requirement: {
|
||||||
|
type: "sessions",
|
||||||
|
target: 30,
|
||||||
|
label: "total sessions completed",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: {
|
||||||
|
xp: 1000,
|
||||||
|
title: "Grand Captain",
|
||||||
|
item: "xp_boost",
|
||||||
|
itemLabel: "Permanent 1.2× XP",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
13
src/hooks/useCrewRank.ts
Normal file
13
src/hooks/useCrewRank.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { QUEST_ARCS } from "../data/questData";
|
||||||
|
|
||||||
|
// Returns the player's current crew rank, or a default if none earned yet
|
||||||
|
export function getCrewRank(arcs = QUEST_ARCS): string {
|
||||||
|
const earned = arcs
|
||||||
|
.flatMap((a) => a.nodes)
|
||||||
|
.filter((n) => n.status === "completed" && n.reward.title)
|
||||||
|
.map((n) => n.reward.title!);
|
||||||
|
|
||||||
|
// Return the last one — questData is ordered by difficulty,
|
||||||
|
// so the last earned title is always the highest rank
|
||||||
|
return earned.at(-1) ?? "Cabin Hand";
|
||||||
|
}
|
||||||
7
src/hooks/usePageTitle.ts
Normal file
7
src/hooks/usePageTitle.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function usePageTitle(title: string) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = title;
|
||||||
|
}, [title]);
|
||||||
|
}
|
||||||
32
src/pages/ErrorPage.tsx
Normal file
32
src/pages/ErrorPage.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// src/pages/ErrorPage.tsx
|
||||||
|
import { useRouteError, isRouteErrorResponse } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ErrorPage() {
|
||||||
|
const error = useRouteError();
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
let title = "Something went wrong";
|
||||||
|
let message = "An unexpected error occurred.";
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
title = `${error.status} ${error.statusText}`;
|
||||||
|
message = error.data?.message || message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="bg-white shadow-xl rounded-2xl p-8 max-w-md text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-4">{title}</h1>
|
||||||
|
<p className="text-gray-600 mb-6">{message}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => (window.location.href = "/")}
|
||||||
|
className="px-4 py-2 bg-black text-white rounded-lg"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,16 +2,184 @@ import { useState, useEffect } from "react";
|
|||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
|
import { Loader2, Mail, Lock } from "lucide-react";
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
from?: {
|
from?: { pathname: string };
|
||||||
pathname: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOTS = [
|
||||||
|
{ size: 12, color: "#f97316", top: "8%", left: "6%", delay: "0s" },
|
||||||
|
{ size: 7, color: "#a855f7", top: "22%", left: "3%", delay: "1.2s" },
|
||||||
|
{ size: 9, color: "#22c55e", top: "65%", left: "5%", delay: "0.6s" },
|
||||||
|
{ size: 8, color: "#f43f5e", top: "80%", left: "8%", delay: "2.1s" },
|
||||||
|
{ size: 12, color: "#3b82f6", top: "10%", right: "6%", delay: "1.8s" },
|
||||||
|
{ size: 7, color: "#eab308", top: "40%", right: "3%", delay: "0.9s" },
|
||||||
|
{ size: 10, color: "#a855f7", top: "72%", right: "5%", delay: "0.4s" },
|
||||||
|
{ size: 8, color: "#f97316", top: "55%", right: "8%", delay: "1.5s" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
.lg-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fffbf4;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blobs */
|
||||||
|
.lg-blob { position:fixed;pointer-events:none;z-index:0; }
|
||||||
|
.lg-blob-1 { width:280px;height:280px;background:#fde68a;top:-100px;left:-100px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:lgWobble1 14s ease-in-out infinite; }
|
||||||
|
.lg-blob-2 { width:220px;height:220px;background:#a5f3c0;bottom:-60px;left:4%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:lgWobble2 16s ease-in-out infinite; }
|
||||||
|
.lg-blob-3 { width:250px;height:250px;background:#fbcfe8;top:10%;right:-70px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:lgWobble1 18s ease-in-out infinite reverse; }
|
||||||
|
.lg-blob-4 { width:180px;height:180px;background:#bfdbfe;bottom:8%;right:0;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:lgWobble2 12s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes lgWobble1 {
|
||||||
|
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(14px,18px) rotate(8deg);}
|
||||||
|
}
|
||||||
|
@keyframes lgWobble2 {
|
||||||
|
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(-12px,14px) rotate(-6deg);}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.28;animation:lgFloat 7s ease-in-out infinite; }
|
||||||
|
@keyframes lgFloat {
|
||||||
|
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||||
|
50%{transform:translateY(-14px) rotate(180deg);}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.lg-card {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 100%; max-width: 400px;
|
||||||
|
background: white; border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04);
|
||||||
|
padding: 2.25rem 2rem 2rem;
|
||||||
|
display: flex; flex-direction: column; gap: 1.75rem;
|
||||||
|
animation: lgPopIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes lgPopIn {
|
||||||
|
from { opacity:0; transform:scale(0.9) translateY(20px); }
|
||||||
|
to { opacity:1; transform:scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo area */
|
||||||
|
.lg-logo-wrap {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.lg-logo-badge {
|
||||||
|
width: 64px; height: 64px; border-radius: 20px;
|
||||||
|
background: linear-gradient(135deg, #a855f7, #7c3aed);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 6px 0 #5b21b655, 0 10px 24px rgba(124,58,237,0.25);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
animation: lgPopIn 0.5s cubic-bezier(0.34,1.56,0.64,1) 0.1s both;
|
||||||
|
}
|
||||||
|
.lg-title {
|
||||||
|
font-size: 1.5rem; font-weight: 900; color: #1e1b4b;
|
||||||
|
letter-spacing: -0.02em; text-align: center;
|
||||||
|
}
|
||||||
|
.lg-sub {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.82rem; font-weight: 600; color: #9ca3af;
|
||||||
|
text-align: center; margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form fields */
|
||||||
|
.lg-fields { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.lg-field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.lg-label {
|
||||||
|
font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: #6b7280;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
.lg-input-wrap { position: relative; }
|
||||||
|
.lg-input-icon {
|
||||||
|
position: absolute; left: 0.85rem; top: 50%;
|
||||||
|
transform: translateY(-50%); pointer-events: none; color: #9ca3af;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.lg-input {
|
||||||
|
width: 100%; padding: 0.8rem 1rem 0.8rem 2.6rem;
|
||||||
|
background: #f9fafb; border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.88rem; font-weight: 600; color: #1e1b4b;
|
||||||
|
outline: none; transition: all 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.lg-input:focus {
|
||||||
|
background: white; border-color: #c4b5fd;
|
||||||
|
box-shadow: 0 0 0 3px rgba(168,85,247,0.1);
|
||||||
|
}
|
||||||
|
.lg-input:focus ~ .lg-input-icon { color: #a855f7; }
|
||||||
|
.lg-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.lg-input::placeholder { color: #d1d5db; }
|
||||||
|
|
||||||
|
/* Remember me */
|
||||||
|
.lg-remember {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0 0.1rem;
|
||||||
|
}
|
||||||
|
.lg-checkbox {
|
||||||
|
width: 18px; height: 18px; border-radius: 6px;
|
||||||
|
accent-color: #a855f7; cursor: pointer; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lg-remember-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.8rem; font-weight: 600; color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.lg-error {
|
||||||
|
background: #fff1f2; border: 2px solid #fecdd3;
|
||||||
|
border-radius: 14px; padding: 0.75rem 1rem;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.82rem; font-weight: 700; color: #e11d48;
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit button */
|
||||||
|
.lg-btn {
|
||||||
|
width: 100%; padding: 0.95rem;
|
||||||
|
background: #f97316; color: white; border: none;
|
||||||
|
border-radius: 100px; cursor: pointer;
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.95rem; font-weight: 900;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.lg-btn:hover { transform:translateY(-2px); box-shadow:0 8px 0 #c2560e,0 12px 24px rgba(249,115,22,0.3); }
|
||||||
|
.lg-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #c2560e; }
|
||||||
|
.lg-btn:disabled {
|
||||||
|
background: #e5e7eb; color: #9ca3af;
|
||||||
|
cursor: not-allowed; box-shadow: 0 4px 0 #d1d5db;
|
||||||
|
}
|
||||||
|
.lg-btn:disabled:hover { transform: none; box-shadow: 0 4px 0 #d1d5db; }
|
||||||
|
|
||||||
|
.lg-spinner { animation: lgSpin 0.8s linear infinite; }
|
||||||
|
@keyframes lgSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Footer hint */
|
||||||
|
.lg-footer {
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.75rem; font-weight: 600; color: #9ca3af;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
const [email, setEmail] = useState<string>("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState<string>("");
|
const [password, setPassword] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@ -20,14 +188,10 @@ export const Login = () => {
|
|||||||
|
|
||||||
const from = (location.state as LocationState)?.from?.pathname || "/student";
|
const from = (location.state as LocationState)?.from?.pathname || "/student";
|
||||||
|
|
||||||
// Redirect if already authenticated
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) navigate("/student/home", { replace: true });
|
||||||
navigate("/student/home", { replace: true });
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, navigate]);
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
// Clear error when component unmounts or inputs change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => clearError();
|
return () => clearError();
|
||||||
}, [clearError]);
|
}, [clearError]);
|
||||||
@ -35,122 +199,139 @@ export const Login = () => {
|
|||||||
const handleSubmit = async (e: FormEvent<HTMLButtonElement>) => {
|
const handleSubmit = async (e: FormEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
const success = await login({ email, password });
|
const success = await login({ email, password });
|
||||||
if (success) {
|
if (success) navigate(from, { replace: true });
|
||||||
navigate(from, { replace: true });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render login form if already authenticated
|
if (isAuthenticated) return null;
|
||||||
if (isAuthenticated) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center ">
|
<div className="lg-screen">
|
||||||
<div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-sm border border-gray-300 space-y-6">
|
<style>{STYLES}</style>
|
||||||
<div className="flex justify-center">
|
|
||||||
|
{/* Blobs */}
|
||||||
|
<div className="lg-blob lg-blob-1" />
|
||||||
|
<div className="lg-blob lg-blob-2" />
|
||||||
|
<div className="lg-blob lg-blob-3" />
|
||||||
|
<div className="lg-blob lg-blob-4" />
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{DOTS.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="lg-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.5 + i * 0.4}s`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="lg-card">
|
||||||
|
{/* Logo + heading */}
|
||||||
|
<div className="lg-logo-wrap space-y-5">
|
||||||
<img
|
<img
|
||||||
src="src/assets/ed_logo.png"
|
src="src/assets/ed_logo.png"
|
||||||
alt="EdBridge logo"
|
alt="EdBridge"
|
||||||
className="h-15 w-auto object-contain"
|
style={{
|
||||||
draggable={false}
|
width: 600,
|
||||||
|
height: 70,
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-satoshi-bold text-center text-gray-800">
|
|
||||||
Welcome Back
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<h1 className="lg-title">Welcome back 👋</h1>
|
||||||
htmlFor="email"
|
<p className="lg-sub">Sign in to continue your SAT prep</p>
|
||||||
className="block text-sm font-satoshi-medium text-gray-700 mb-2"
|
</div>
|
||||||
>
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="lg-fields">
|
||||||
|
{/* Email */}
|
||||||
|
<div className="lg-field">
|
||||||
|
<label className="lg-label" htmlFor="email">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
|
<div className="lg-input-wrap">
|
||||||
|
<Mail size={15} className="lg-input-icon" />
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
className="lg-input"
|
||||||
|
placeholder="you@example.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Password */}
|
||||||
<label
|
<div className="lg-field">
|
||||||
htmlFor="password"
|
<label className="lg-label" htmlFor="password">
|
||||||
className="block text-sm font-satoshi-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
|
<div className="lg-input-wrap">
|
||||||
|
<Lock size={15} className="lg-input-icon" />
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
className="lg-input"
|
||||||
|
placeholder="••••••••"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center mt-4">
|
|
||||||
<input
|
|
||||||
id="rememberMe"
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="rememberMe"
|
|
||||||
className="ml-2 block text-sm font-satoshi-medium text-gray-700"
|
|
||||||
>
|
|
||||||
Remember me
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Remember me */}
|
||||||
|
<div className="lg-remember">
|
||||||
|
<input id="rememberMe" type="checkbox" className="lg-checkbox" />
|
||||||
|
<label htmlFor="rememberMe" className="lg-remember-label">
|
||||||
|
Keep me signed in
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
<div className="lg-error">
|
||||||
{error}
|
<span>⚠️</span> {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
|
className="lg-btn"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isLoading || !email || !password}
|
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"
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<svg
|
<Loader2 size={18} className="lg-spinner" /> Signing in...
|
||||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Signing in...
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Sign In"
|
"Sign In →"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="lg-footer">
|
||||||
|
By signing in you agree to Edbridge's Terms & Privacy Policy.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,396 +1,529 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsTrigger,
|
|
||||||
TabsList,
|
|
||||||
TabsContent,
|
|
||||||
} from "../../components/ui/tabs";
|
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
import { CheckCircle, Flame, Search, Zap } from "lucide-react";
|
import { CheckCircle, Play, Search } from "lucide-react";
|
||||||
import { api } from "../../utils/api";
|
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 type { PracticeSheet } from "../../types/sheet";
|
||||||
import { formatStatus } from "../../lib/utils";
|
import { formatStatus } from "../../lib/utils";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { SearchOverlay } from "../../components/SearchOverlay";
|
import { SearchOverlay } from "../../components/SearchOverlay";
|
||||||
import { PredictedScoreCard } from "../../components/PredictedScoreCard";
|
import { InfoHeader } from "../../components/InfoHeader";
|
||||||
import {
|
|
||||||
Avatar,
|
// ─── Shared blob/dot background (same as break/results screens) ────────────────
|
||||||
AvatarFallback,
|
const DOTS = [
|
||||||
AvatarImage,
|
{ size: 12, color: "#f97316", top: "8%", left: "6%", delay: "0s" },
|
||||||
} from "../../components/ui/avatar";
|
{ size: 8, color: "#a855f7", top: "22%", left: "2%", delay: "1s" },
|
||||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
{ 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');
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.home-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fffbf4;
|
||||||
|
font-family: 'Nunito', 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 ── */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Load more ── */
|
||||||
|
.h-load-more-btn {
|
||||||
|
width: 100%; margin-top: 0.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white; border: 2.5px solid #f3f4f6;
|
||||||
|
border-radius: 100px; cursor: pointer;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.82rem; font-weight: 800; color: #9ca3af;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.4rem;
|
||||||
|
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.h-load-more-btn:hover { border-color: #c4b5fd; color: #a855f7; background: #fdf4ff; transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.06); }
|
||||||
|
.h-load-more-btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.h-sheet-count {
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.72rem; font-weight: 600; color: #d1d5db;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.h-sheet-count span { font-weight: 800; color: #9ca3af; }
|
||||||
|
|
||||||
|
@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; }
|
||||||
|
|
||||||
|
/* Desktop / wide tweaks */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.home-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
|
||||||
|
.h-sheet-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||||
|
|
||||||
|
/* nudge blobs so they align visually with the centered container */
|
||||||
|
.h-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
|
||||||
|
.h-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
|
||||||
|
.h-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
|
||||||
|
.h-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── 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 ───────────────────────────────────────────────────────────
|
||||||
|
const PAGE_SIZE = 6;
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const userXp = useExamConfigStore.getState().userXp;
|
|
||||||
|
|
||||||
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
||||||
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
||||||
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
|
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
|
||||||
const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]);
|
const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
"all" | "NOT_STARTED" | "COMPLETED"
|
||||||
|
>("all");
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sortPracticeSheets = (sheets: PracticeSheet[]) => {
|
const sort = (sheets: PracticeSheet[]) => {
|
||||||
const notStarted = sheets.filter(
|
setNotStartedSheets(
|
||||||
(sheet) => sheet.user_status === "NOT_STARTED",
|
sheets.filter((s) => s.user_status === "NOT_STARTED"),
|
||||||
);
|
);
|
||||||
const inProgress = sheets.filter(
|
setInProgressSheets(
|
||||||
(sheet) => sheet.user_status === "IN_PROGRESS",
|
sheets.filter((s) => s.user_status === "IN_PROGRESS"),
|
||||||
);
|
);
|
||||||
const completed = sheets.filter(
|
setCompletedSheets(sheets.filter((s) => s.user_status === "COMPLETED"));
|
||||||
(sheet) => sheet.user_status === "COMPLETED",
|
|
||||||
);
|
|
||||||
|
|
||||||
setNotStartedSheets(notStarted);
|
|
||||||
setInProgressSheets(inProgress);
|
|
||||||
setCompletedSheets(completed);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPracticeSheets = async () => {
|
const fetch = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authStorage = localStorage.getItem("auth-storage");
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
if (!authStorage) {
|
if (!authStorage) return;
|
||||||
console.error("authStorage not found in local storage");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const {
|
const {
|
||||||
state: { token },
|
state: { token },
|
||||||
} = JSON.parse(authStorage);
|
} = JSON.parse(authStorage);
|
||||||
if (!token) {
|
if (!token) return;
|
||||||
console.error("Token not found in authStorage");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sheets = await api.getPracticeSheets(token, 1, 10);
|
const sheets = await api.getPracticeSheets(token, 1, 10);
|
||||||
setPracticeSheets(sheets.data);
|
setPracticeSheets(sheets.data);
|
||||||
sortPracticeSheets(sheets.data);
|
sort(sheets.data);
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error("Error fetching practice sheets:", error);
|
console.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
fetch();
|
||||||
fetchPracticeSheets();
|
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleStartPracticeSheet = (sheetId: string) => {
|
const handleStart = (id: string) => navigate(`/student/practice/${id}`);
|
||||||
navigate(`/student/practice/${sheetId}`);
|
|
||||||
|
const allTabSheets =
|
||||||
|
activeTab === "all"
|
||||||
|
? practiceSheets
|
||||||
|
: activeTab === "NOT_STARTED"
|
||||||
|
? notStartedSheets
|
||||||
|
: completedSheets;
|
||||||
|
|
||||||
|
const tabSheets = allTabSheets.slice(0, visibleCount);
|
||||||
|
const hasMore = visibleCount < allTabSheets.length;
|
||||||
|
const remaining = allTabSheets.length - visibleCount;
|
||||||
|
|
||||||
|
const handleTabChange = (tab: "all" | "NOT_STARTED" | "COMPLETED") => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setVisibleCount(PAGE_SIZE);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-12">
|
<div className="home-screen">
|
||||||
<header className="flex items-center gap-3 justify-between">
|
<style>{STYLES}</style>
|
||||||
<div className="flex gap-3">
|
|
||||||
<Avatar className="w-12 h-12">
|
|
||||||
<AvatarImage src={user?.avatar_url} />
|
|
||||||
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-indigo-400 to-indigo-500 uppercase text-lg text-white">
|
|
||||||
{user?.name.slice(0, 1)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h1 className="text-xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
|
||||||
Welcome, {user?.name || "Student"}
|
|
||||||
</h1>
|
|
||||||
<h4 className="text-sm font-satoshi-bold text-indigo-500 ">
|
|
||||||
{user?.role === "STUDENT"
|
|
||||||
? "Student"
|
|
||||||
: user?.role === "ADMIN"
|
|
||||||
? "Admin"
|
|
||||||
: "Taecher"}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="rounded-full w-fit flex items-center gap-2">
|
|
||||||
<Flame size={20} className="text-red-500 fill-amber-200" />
|
|
||||||
|
|
||||||
<span className="font-satoshi-bold text-md">5</span>
|
{/* Blobs */}
|
||||||
</div>
|
<div className="h-blob h-blob-1" />
|
||||||
<div className="rounded-full w-fit flex items-center gap-2">
|
<div className="h-blob h-blob-2" />
|
||||||
<Zap size={20} className="text-lime-500 fill-lime-200" />
|
<div className="h-blob h-blob-3" />
|
||||||
|
<div className="h-blob h-blob-4" />
|
||||||
|
|
||||||
<span className="font-satoshi-bold text-md">{userXp}</span>
|
{/* Dots */}
|
||||||
</div>
|
{DOTS.map((d, i) => (
|
||||||
</div>
|
<div
|
||||||
</header>
|
key={i}
|
||||||
<PredictedScoreCard />
|
className="h-dot"
|
||||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
style={
|
||||||
What are you looking for?
|
{
|
||||||
</h1>
|
width: d.size,
|
||||||
<section className="relative w-full">
|
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 ── */}
|
||||||
|
<InfoHeader
|
||||||
|
mode="DEFAULT"
|
||||||
|
onViewAll={() => navigate("/student/quests")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Search ── */}
|
||||||
|
<div className="h-search-wrap h-anim h-anim-1">
|
||||||
|
<span className="h-search-icon">
|
||||||
|
<Search size={18} color="#9ca3af" />
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
onFocus={() => setIsSearchOpen(true)}
|
className="h-search-input"
|
||||||
placeholder="Search practice sheets..."
|
placeholder="Search practice sheets..."
|
||||||
readOnly
|
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>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-4">
|
{/* ── In progress ── */}
|
||||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
<section className="h-anim h-anim-2">
|
||||||
Pick up where you left off
|
<p className="h-section-title">📌 Pick up where you left off</p>
|
||||||
</h1>
|
|
||||||
{inProgressSheets.length > 0 ? (
|
{inProgressSheets.length > 0 ? (
|
||||||
inProgressSheets.map((sheet) => (
|
<div
|
||||||
<Card
|
style={{
|
||||||
key={sheet?.id}
|
display: "flex",
|
||||||
className="rounded-4xl border bg-indigo-50/70 border-indigo-500"
|
flexDirection: "column",
|
||||||
|
gap: "0.65rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
{inProgressSheets.map((sheet) => (
|
||||||
<CardTitle className="font-satoshi-medium text-xl">
|
<div
|
||||||
{sheet?.title}
|
key={sheet.id}
|
||||||
</CardTitle>
|
className="h-inprogress-card"
|
||||||
<CardDescription className="font-satoshi">
|
onClick={() => handleStart(sheet.id)}
|
||||||
{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"
|
|
||||||
>
|
>
|
||||||
{sheet?.modules_count} modules
|
<div className="h-inprogress-info">
|
||||||
</Badge>
|
<p className="h-inprogress-title">{sheet.title}</p>
|
||||||
</CardContent>
|
<span className="h-inprogress-badge">In Progress</span>
|
||||||
<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>
|
</div>
|
||||||
)}
|
<button
|
||||||
|
className="h-play-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleStart(sheet.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play size={18} color="white" fill="white" />
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
</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">
|
<div className="h-empty">
|
||||||
<h2 className="text-center font-satoshi text-lg text-gray-500">
|
<span className="h-empty-emoji">🎯</span>
|
||||||
You have not completed any practice sheets.
|
No sheets in progress — start one below!
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4 ">
|
{/* ── All sheets with tabs ── */}
|
||||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
<section className="h-anim h-anim-3">
|
||||||
SAT Preparation Tips
|
<p className="h-section-title">📋 Practice Sheets</p>
|
||||||
</h1>
|
|
||||||
<section className="space-y-4 ">
|
{/* Tab buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="h-tabs-list">
|
||||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
{(["all", "NOT_STARTED", "COMPLETED"] as const).map((tab) => (
|
||||||
<p className="font-satoshi text-md">
|
<button
|
||||||
Practice regularly with official SAT materials
|
key={tab}
|
||||||
</p>
|
className={`h-tab-btn${activeTab === tab ? " active" : ""}`}
|
||||||
|
onClick={() => handleTabChange(tab)}
|
||||||
|
>
|
||||||
|
{tab === "all"
|
||||||
|
? "All"
|
||||||
|
: tab === "NOT_STARTED"
|
||||||
|
? "Not Started"
|
||||||
|
: "Completed"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
{allTabSheets.length > 0 ? (
|
||||||
<p className="font-satoshi text-md">
|
<>
|
||||||
Review your mistakes and learn from them
|
<div className="h-sheet-grid">
|
||||||
</p>
|
{tabSheets.map((sheet) => (
|
||||||
|
<SheetCard
|
||||||
|
key={sheet.id}
|
||||||
|
sheet={sheet}
|
||||||
|
onStart={handleStart}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{hasMore ? (
|
||||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
<button
|
||||||
<p className="font-satoshi text-md">Focus on your weak areas</p>
|
className="h-load-more-btn"
|
||||||
</div>
|
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
↓ Show {Math.min(remaining, PAGE_SIZE)} more
|
||||||
<p className="font-satoshi text-md">
|
</button>
|
||||||
Take full-length practice tests
|
) : allTabSheets.length > PAGE_SIZE ? (
|
||||||
|
<p className="h-sheet-count">
|
||||||
|
Showing all <span>{allTabSheets.length}</span> sheets
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-empty">
|
||||||
|
<span className="h-empty-emoji">🔍</span>
|
||||||
|
Nothing here yet!
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<CheckCircle size={24} color="oklch(58.5% 0.233 277.117)" />
|
</section>
|
||||||
<p className="font-satoshi text-md">
|
|
||||||
Get plenty of rest before the test day
|
{/* ── Tips ── */}
|
||||||
</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{isSearchOpen && (
|
{isSearchOpen && (
|
||||||
<SearchOverlay
|
<SearchOverlay
|
||||||
sheets={practiceSheets}
|
sheets={practiceSheets}
|
||||||
@ -402,6 +535,6 @@ export const Home = () => {
|
|||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={setSearchQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,188 +1,309 @@
|
|||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
import { useEffect, useState } from "react";
|
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 { api } from "../../utils/api";
|
||||||
import { type Lesson } from "../../types/lesson";
|
import { type Lesson } from "../../types/lesson";
|
||||||
import { LessonSkeleton } from "../../components/LessonSkeleton";
|
import { LessonSkeleton } from "../../components/LessonSkeleton";
|
||||||
import { LessonModal } from "../../components/LessonModal";
|
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');
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.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; } }
|
||||||
|
|
||||||
|
/* Desktop / wide layout tweaks */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.ls-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
|
||||||
|
.ls-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||||
|
.ls-skeleton-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||||
|
|
||||||
|
/* nudge blobs closer to the centered content container */
|
||||||
|
.ls-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
|
||||||
|
.ls-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
|
||||||
|
.ls-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
|
||||||
|
.ls-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const Lessons = () => {
|
export const Lessons = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
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 [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleLessonClick = (lessonId: string) => {
|
const handleLessonClick = (id: string) => {
|
||||||
setSelectedLessonId(lessonId);
|
setSelectedLessonId(id);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAllLessons = async () => {
|
const fetchAllLessons = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLessonlLoading(true);
|
setLessonLoading(true);
|
||||||
const authStorage = localStorage.getItem("auth-storage");
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
if (!authStorage) return;
|
if (!authStorage) return;
|
||||||
|
const {
|
||||||
const parsed = JSON.parse(authStorage) as {
|
state: { token },
|
||||||
state?: { token?: string };
|
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||||
};
|
|
||||||
|
|
||||||
const token = parsed.state?.token;
|
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const response = await api.fetchAllLessons(token);
|
const response = await api.fetchAllLessons(token);
|
||||||
|
|
||||||
setLessonlLoading(false);
|
|
||||||
setLessons(response.data);
|
setLessons(response.data);
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
setLessonlLoading(false);
|
console.error(e);
|
||||||
console.error("Error fetching lessons:", error);
|
} finally {
|
||||||
|
setLessonLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAllLessons();
|
fetchAllLessons();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const renderGrid = (variant: "rw" | "math") => {
|
||||||
|
if (lessonLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen space-y-6 max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8">
|
<div className="ls-skeleton-grid">
|
||||||
<header className="space-y-2">
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<h1 className="font-satoshi-black text-2xl">Lessons</h1>
|
<LessonSkeleton key={i} />
|
||||||
<p className="font-satoshi-medium text-sm text-gray-500">
|
))}
|
||||||
Browse step-by-step lessons from expert Edbridge tutors and pick up
|
</div>
|
||||||
tips to tackle similar questions with confidence.
|
);
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ls-screen pb-12">
|
||||||
|
<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>
|
</p>
|
||||||
</header>
|
</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>
|
|
||||||
)}
|
|
||||||
{!lessonLoading && lessons.length === 0 && (
|
|
||||||
<div className="text-center text-muted-foreground py-12">
|
|
||||||
No lessons available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!lessonLoading && lessons.length > 0 && (
|
{/* Tabs + content */}
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<section className="ls-anim ls-anim-1">
|
||||||
{lessons.map((lesson) => (
|
<div className="ls-tabs-list">
|
||||||
<Card
|
<button
|
||||||
key={lesson.id}
|
className={`ls-tab-btn${activeTab === "rw" ? " active" : ""}`}
|
||||||
onClick={() => handleLessonClick(lesson.id)}
|
onClick={() => setActiveTab("rw")}
|
||||||
className="py-0 pb-5 rounded-4xl overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<CardHeader className="w-full py-0 px-0">
|
<BookOpen size={15} /> Reading & Writing
|
||||||
<img
|
</button>
|
||||||
src={lesson.thumbnail_url}
|
<button
|
||||||
alt={lesson.title}
|
className={`ls-tab-btn${activeTab === "math" ? " active" : ""}`}
|
||||||
className="w-full h-auto"
|
onClick={() => setActiveTab("math")}
|
||||||
/>
|
|
||||||
</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">
|
<Calculator size={15} /> Math
|
||||||
<img
|
</button>
|
||||||
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>
|
</div>
|
||||||
)}
|
|
||||||
<LessonModal
|
{renderGrid(activeTab)}
|
||||||
open={isModalOpen}
|
|
||||||
lessonId={selectedLessonId}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsModalOpen(open);
|
|
||||||
if (!open) setSelectedLessonId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
|
<LessonModal
|
||||||
|
open={isModalOpen}
|
||||||
|
lessonId={selectedLessonId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsModalOpen(open);
|
||||||
|
if (!open) setSelectedLessonId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,134 +7,334 @@ import {
|
|||||||
Trophy,
|
Trophy,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} 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 { useNavigate } from "react-router-dom";
|
||||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||||
|
import { LevelBar } from "../../components/LevelBar";
|
||||||
|
import { InfoHeader } from "../../components/InfoHeader";
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop / wide layout */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.pr-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
|
||||||
|
.pr-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||||
|
|
||||||
|
.pr-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
|
||||||
|
.pr-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
|
||||||
|
.pr-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
|
||||||
|
.pr-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
|
||||||
|
|
||||||
|
.pr-hero { padding: 2rem; }
|
||||||
|
.pr-hero-icon-bg { right: -60px; top: -40px; opacity: 0.12; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 = () => {
|
export const Practice = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const userXp = useExamConfigStore.getState().userXp;
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-8 space-y-4">
|
<div className="pr-screen">
|
||||||
<header className="flex justify-between items-center ">
|
<style>{STYLES}</style>
|
||||||
<div className="w-fit bg-linear-to-br from-indigo-500 to-indigo-600 p-3 rounded-2xl">
|
|
||||||
<BookOpen size={20} color="white" />
|
|
||||||
</div>
|
|
||||||
<div className="bg-indigo-100 rounded-full w-fit py-2 px-4 flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 bg-linear-to-br from-indigo-400 to-indigo-500 rounded-full"></div>
|
|
||||||
<span className="font-satoshi-bold text-md">{userXp}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<section>
|
|
||||||
<Card
|
|
||||||
className="relative bg-linear-to-br from-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">
|
{/* Blobs */}
|
||||||
<p className="font-satoshi text-white">
|
<div className="pr-blob pr-blob-1" />
|
||||||
Test your knowledge with an adaptive practice test.
|
<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 ── */}
|
||||||
|
<InfoHeader mode="LEVEL" />
|
||||||
|
{/* ── 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>
|
</p>
|
||||||
</CardContent>
|
<button className="pr-hero-btn">Take a practice test →</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-10 -right-20">
|
|
||||||
<DraftingCompass size={300} color="white" />
|
{/* ── Practice modes ── */}
|
||||||
</div>
|
<section className="pr-anim pr-anim-2">
|
||||||
</Card>
|
<p className="pr-section-title" style={{ marginBottom: "0.85rem" }}>
|
||||||
</section>
|
Practice your way
|
||||||
<section className="flex flex-col gap-6">
|
</p>
|
||||||
<h1 className="font-satoshi-black text-2xl">Practice your way</h1>
|
<div className="pr-grid">
|
||||||
<div className="md:grid md:grid-cols-2 md:gap-6 space-y-6 md:space-y-0">
|
{MODE_CARDS.map((card) => (
|
||||||
<Card
|
<div
|
||||||
onClick={() => navigate("/student/practice/targeted-practice")}
|
key={card.route}
|
||||||
className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"
|
className={`pr-mode-card ${card.color}`}
|
||||||
|
onClick={() => navigate(card.route)}
|
||||||
>
|
>
|
||||||
<CardHeader className="space-y-3">
|
<div className="pr-mode-top">
|
||||||
<div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl">
|
<div className={`pr-mode-icon ${card.color}`}>
|
||||||
<Target size={20} color="white" />
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className={`pr-mode-badge ${card.color}`}>
|
||||||
<CardTitle className="font-satoshi">
|
{card.badge}
|
||||||
Targeted Practice
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="font-satoshi">
|
|
||||||
Focus on what matters
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<CardAction>
|
|
||||||
<div className="w-fit bg-red-100 p-2 rounded-full">
|
|
||||||
<Loader2 size={30} color="#fa6969" />
|
|
||||||
</div>
|
</div>
|
||||||
</CardAction>
|
<div>
|
||||||
</CardHeader>
|
<p className="pr-mode-title">{card.title}</p>
|
||||||
</Card>
|
<p className="pr-mode-desc">{card.desc}</p>
|
||||||
<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>
|
</div>
|
||||||
<div className="space-y-2">
|
<p className={`pr-mode-arrow ${card.color}`}>{card.arrow}</p>
|
||||||
<CardTitle className="font-satoshi">Drills</CardTitle>
|
|
||||||
<CardDescription className="font-satoshi">
|
|
||||||
Train speed and accuracy
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,216 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight, LogOut, User } from "lucide-react";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
import { useNavigate } from "react-router-dom";
|
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');
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop / web layout: wider container and two-column grid */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.pf-inner {
|
||||||
|
max-width: var(--content-max);
|
||||||
|
padding: 2.5rem 2.5rem 4rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 420px;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
gap: 1.5rem 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero spans full width */
|
||||||
|
.pf-hero { grid-column: 1 / -1; display: flex; align-items: center; gap: 1.5rem; }
|
||||||
|
|
||||||
|
/* Keep page title centered across the full layout */
|
||||||
|
.pf-page-title { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
/* Place first section (Account) in left column */
|
||||||
|
.pf-inner > section:nth-of-type(1) { grid-column: 1 / 2; }
|
||||||
|
|
||||||
|
/* Right column wrapper (Legal + Support) */
|
||||||
|
.pf-right-col { grid-column: 2 / 3; display: flex; flex-direction: column; gap: 1.5rem; }
|
||||||
|
|
||||||
|
/* Make signout button centered and constrained width */
|
||||||
|
.pf-signout-btn { grid-column: 1 / -1; justify-self: center; width: 420px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: not-allowed;
|
||||||
|
background: white; border: none; width: 100%; text-align: left;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
border-bottom: 2px solid #f9fafb;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.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 = () => {
|
export const Profile = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
@ -11,62 +221,143 @@ export const Profile = () => {
|
|||||||
navigate("/login");
|
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 (
|
return (
|
||||||
<main className="min-h-screen space-y-6 mx-auto p-8">
|
<div className="pf-screen pb-12">
|
||||||
<h1 className="text-lg font-satoshi-bold text-center">Profile</h1>
|
<style>{STYLES}</style>
|
||||||
<section>
|
|
||||||
<h3 className="text-2xl font-satoshi-bold">{user?.name}</h3>
|
{/* Blobs */}
|
||||||
<p className="text-lg font-satoshi">{user?.email}</p>
|
<div className="pf-blob pf-blob-1" />
|
||||||
</section>
|
<div className="pf-blob pf-blob-2" />
|
||||||
<section className="space-y-4">
|
<div className="pf-blob pf-blob-3" />
|
||||||
<h5 className="text-[12px] font-satoshi-bold text-gray-400">ACCOUNT</h5>
|
<div className="pf-blob pf-blob-4" />
|
||||||
<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">
|
{/* Dots */}
|
||||||
<span className="font-satoshi text-black">Account details</span>
|
{DOTS.map((d, i) => (
|
||||||
<ChevronRight color="gray" size={20} />
|
<div
|
||||||
</button>
|
key={i}
|
||||||
<button className="w-full border-b bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
className="pf-dot"
|
||||||
<span className="font-satoshi text-black">Redeem a code</span>
|
style={
|
||||||
<ChevronRight color="gray" size={20} />
|
{
|
||||||
</button>
|
width: d.size,
|
||||||
<button className="w-full border-b bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
height: d.size,
|
||||||
<span className="font-satoshi text-black">Manage subscription</span>
|
background: d.color,
|
||||||
<ChevronRight color="gray" size={20} />
|
top: d.top,
|
||||||
</button>
|
left: d.left,
|
||||||
<button className="w-full rounded-b-4xl bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
right: d.right,
|
||||||
<span className="font-satoshi text-black">Preferences</span>
|
animationDelay: d.delay,
|
||||||
<ChevronRight color="gray" size={20} />
|
animationDuration: `${5 + i * 0.5}s`,
|
||||||
</button>
|
} as React.CSSProperties
|
||||||
</div>
|
}
|
||||||
</section>
|
/>
|
||||||
<section className="space-y-4">
|
))}
|
||||||
<h5 className="text-[12px] font-satoshi-bold text-gray-400">LEGAL</h5>
|
|
||||||
<div className="border rounded-4xl">
|
<div className="pf-inner">
|
||||||
<button className="w-full border-b rounded-t-4xl bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
<h1 className="pf-page-title">Profile</h1>
|
||||||
<span className="font-satoshi text-black">Terms of Use</span>
|
|
||||||
<ChevronRight color="gray" size={20} />
|
{/* Hero card */}
|
||||||
</button>
|
<div className="pf-hero pf-anim pf-anim-1">
|
||||||
<button className="w-full border-b rounded-b-4xl bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
<Avatar style={{ width: 64, height: 64, flexShrink: 0 }}>
|
||||||
<span className="font-satoshi text-black">Privacy Policy</span>
|
<AvatarImage src={user?.avatar_url} />
|
||||||
<ChevronRight color="gray" size={20} />
|
<AvatarFallback
|
||||||
</button>
|
style={{
|
||||||
</div>
|
width: 64,
|
||||||
</section>
|
height: 64,
|
||||||
<section className="space-y-4">
|
borderRadius: "50%",
|
||||||
<h5 className="text-[12px] font-satoshi-bold text-gray-400">SUPPORT</h5>
|
background: "linear-gradient(135deg,#a855f7,#7c3aed)",
|
||||||
<div className="border rounded-4xl">
|
color: "white",
|
||||||
<button className="w-full rounded-4xl bg-white py-4 px-4 flex justify-between items-center active:bg-gray-50">
|
fontWeight: 900,
|
||||||
<span className="font-satoshi text-black">Contact Us</span>
|
fontSize: "1.5rem",
|
||||||
<ChevronRight color="gray" size={20} />
|
display: "flex",
|
||||||
</button>
|
alignItems: "center",
|
||||||
</div>
|
justifyContent: "center",
|
||||||
</section>
|
boxShadow: "0 4px 0 #5b21b655",
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
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>
|
||||||
|
|
||||||
|
{/* Right column: Legal + Support (stacked) */}
|
||||||
|
<div className="pf-right-col pf-anim pf-anim-3">
|
||||||
|
<section className="pf-section">
|
||||||
|
<p className="pf-section-label">Legal</p>
|
||||||
|
<SettingsGroup rows={LEGAL_ROWS} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="pf-section">
|
||||||
|
<p className="pf-section-label">Support</p>
|
||||||
|
<SettingsGroup rows={SUPPORT_ROWS} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign out */}
|
||||||
|
<button
|
||||||
|
className="pf-signout-btn pf-anim pf-anim-5"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut size={18} /> Sign Out
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
998
src/pages/student/QuestMap.tsx
Normal file
998
src/pages/student/QuestMap.tsx
Normal file
@ -0,0 +1,998 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { Lock, CheckCircle } from "lucide-react";
|
||||||
|
import type { QuestArc, QuestNode, NodeStatus } from "../../types/quest";
|
||||||
|
import { useQuestStore, getQuestSummary } from "../../stores/useQuestStore";
|
||||||
|
import { QuestNodeModal } from "../../components/QuestNodeModal";
|
||||||
|
import { ChestOpenModal } from "../../components/ChestOpenModal";
|
||||||
|
import { InfoHeader } from "../../components/InfoHeader";
|
||||||
|
|
||||||
|
// ─── Map geometry (all in SVG user-units, viewBox width = 390) ───────────────
|
||||||
|
const VW = 390; // viewBox width — matches typical phone width
|
||||||
|
const ROW_GAP = 260; // vertical distance between island centres
|
||||||
|
const TOP_PAD = 80; // y of first island centre
|
||||||
|
|
||||||
|
// Three column x-centres: Left=22%, Centre=50%, Right=78%
|
||||||
|
const COL_X = [
|
||||||
|
Math.round(VW * 0.22), // 86
|
||||||
|
Math.round(VW * 0.5), // 195
|
||||||
|
Math.round(VW * 0.78), // 304
|
||||||
|
];
|
||||||
|
// Per-arc column sequences — each arc winds differently across the map.
|
||||||
|
// 0 = Left (22%), 1 = Centre (50%), 2 = Right (78%)
|
||||||
|
const ARC_COL_SEQS: Record<string, number[]> = {
|
||||||
|
east_blue: [0, 1, 2, 0, 1, 2], // steady L→C→R march
|
||||||
|
alabasta: [2, 0, 2, 1, 0, 2], // sharp zigzag, heavy right bias
|
||||||
|
skypiea: [1, 2, 0, 2, 0, 1], // wide sweeping swings C→R→L→R→L→C
|
||||||
|
};
|
||||||
|
const COL_SEQ_DEFAULT = [0, 1, 2, 0, 1, 2];
|
||||||
|
|
||||||
|
// Card half-width / half-height for the foreign-object card
|
||||||
|
const CARD_W = 130;
|
||||||
|
const CARD_H = 195;
|
||||||
|
|
||||||
|
const islandCX = (i: number, arcId: string) => {
|
||||||
|
const seq = ARC_COL_SEQS[arcId] ?? COL_SEQ_DEFAULT;
|
||||||
|
return COL_X[seq[i % seq.length]];
|
||||||
|
};
|
||||||
|
const islandCY = (i: number) => TOP_PAD + i * ROW_GAP;
|
||||||
|
|
||||||
|
// Total SVG height
|
||||||
|
const svgHeight = (n: number) => TOP_PAD + (n - 1) * ROW_GAP + TOP_PAD + CARD_H;
|
||||||
|
|
||||||
|
// ─── Island shapes (clip-path on a 110×65 rect centred at 0,0) ───────────────
|
||||||
|
const SHAPES = [
|
||||||
|
// 0: fat round atoll
|
||||||
|
`<ellipse cx="0" cy="0" rx="57" ry="33"/>`,
|
||||||
|
// 1: tall mountain peak
|
||||||
|
`<polygon points="0,-38 28,-14 48,10 40,33 22,38 -22,38 -40,33 -48,10 -28,-14"/>`,
|
||||||
|
// 2: wide flat shoal
|
||||||
|
`<ellipse cx="0" cy="5" rx="62" ry="26"/>`,
|
||||||
|
// 3: jagged rocky reef
|
||||||
|
`<polygon points="0,-38 20,-14 50,-8 32,12 42,36 16,24 0,38 -16,24 -42,36 -32,12 -50,-8 -20,-14"/>`,
|
||||||
|
// 4: crescent (right side bites in)
|
||||||
|
`<path d="M-50,0 C-50,-34 -20,-38 0,-36 C22,-34 48,-18 50,4 C52,24 36,30 18,24 C6,20 4,10 10,4 C16,-4 26,-4 28,4 C30,12 22,18 12,16 C-4,10 -10,-8 0,-20 C12,-32 -30,-28 -50,0 Z"/>`,
|
||||||
|
// 5: teardrop/pear
|
||||||
|
`<path d="M0,-38 C18,-38 44,-18 44,8 C44,28 26,38 0,38 C-26,38 -44,28 -44,8 C-44,-18 -18,-38 0,-38 Z"/>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy:ital@0;1&display=swap');
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.qm-screen {
|
||||||
|
height: 100vh; font-family: 'Nunito', sans-serif;
|
||||||
|
position: relative; display: flex; flex-direction: column;
|
||||||
|
overflow: hidden; background: #060e1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ HEADER ══ */
|
||||||
|
.qm-header {
|
||||||
|
position: relative; z-index: 30; flex-shrink: 0;
|
||||||
|
background: rgba(4,10,24,0.94);
|
||||||
|
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(251,191,36,0.12);
|
||||||
|
padding: 1.25rem 1.25rem 0;
|
||||||
|
}
|
||||||
|
.qm-page-title {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 1.3rem; font-weight: 900; letter-spacing: 0.05em;
|
||||||
|
color: #fbbf24;
|
||||||
|
text-shadow: 0 0 24px rgba(251,191,36,0.5), 0 0 60px rgba(251,191,36,0.15);
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
.qm-page-sub {
|
||||||
|
font-family: 'Nunito Sans', sans-serif; font-size: 0.7rem; font-weight: 600;
|
||||||
|
color: rgba(255,255,255,0.35); margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.qm-stats-strip {
|
||||||
|
display: flex; gap: 0.4rem; overflow-x: auto;
|
||||||
|
scrollbar-width: none; padding-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.qm-stats-strip::-webkit-scrollbar { display:none; }
|
||||||
|
.qm-stat-chip {
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
padding: 0.28rem 0.7rem; flex-shrink: 0;
|
||||||
|
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
.qm-stat-val { font-size:0.76rem; font-weight:900; color:#fbbf24; }
|
||||||
|
.qm-stat-label { font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:600; color:rgba(255,255,255,0.35); }
|
||||||
|
.qm-arc-tabs {
|
||||||
|
display: flex; gap:0; overflow-x:auto; scrollbar-width:none;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.qm-arc-tabs::-webkit-scrollbar { display:none; }
|
||||||
|
.qm-arc-tab {
|
||||||
|
flex-shrink:0; display:flex; align-items:center; gap:0.4rem;
|
||||||
|
padding: 0.6rem 1rem; border:none; background:transparent; cursor:pointer;
|
||||||
|
font-family:'Nunito',sans-serif; font-weight:800; font-size:0.78rem;
|
||||||
|
color: rgba(255,255,255,0.3); border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.2s ease; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.qm-arc-tab:hover { color:rgba(255,255,255,0.6); }
|
||||||
|
.qm-arc-tab.active { color:var(--arc-accent); border-bottom-color:var(--arc-accent); }
|
||||||
|
.qm-tab-dot {
|
||||||
|
width:7px; height:7px; border-radius:50%;
|
||||||
|
background:#ef4444; box-shadow:0 0 8px #ef4444;
|
||||||
|
animation: qmDotBlink 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes qmDotBlink { 0%,100%{ opacity:1; } 50%{ opacity:0.4; } }
|
||||||
|
|
||||||
|
/* ══ SEA ══ */
|
||||||
|
.qm-sea-scroll {
|
||||||
|
flex:1; overflow-y:auto; overflow-x:hidden;
|
||||||
|
position:relative; scrollbar-width:none; -webkit-overflow-scrolling:touch;
|
||||||
|
}
|
||||||
|
.qm-sea-scroll::-webkit-scrollbar { display:none; }
|
||||||
|
.qm-sea {
|
||||||
|
position:relative; min-height:100%;
|
||||||
|
padding: 1.25rem 1.25rem 8rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 40% at 20% 15%, rgba(6,80,160,0.45) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 80% 60%, rgba(4,50,110,0.35) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 70% 40% at 50% 90%, rgba(8,120,180,0.2) 0%, transparent 50%),
|
||||||
|
linear-gradient(180deg, #071530 0%, #04101e 40%, #020a14 100%);
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.qm-sea-shimmer {
|
||||||
|
position:absolute; inset:0; pointer-events:none; z-index:0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(105deg, transparent 0%, transparent 55%, rgba(56,189,248,0.018) 56%, transparent 57%),
|
||||||
|
repeating-linear-gradient(75deg, transparent 0%, transparent 70%, rgba(56,189,248,0.012) 71%, transparent 72%);
|
||||||
|
background-size: 400% 400%, 300% 300%;
|
||||||
|
animation: qmSeaMove 14s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes qmSeaMove {
|
||||||
|
0% { background-position: 0% 0%, 100% 0%; }
|
||||||
|
100% { background-position: 100% 100%, 0% 100%; }
|
||||||
|
}
|
||||||
|
.qm-bubble {
|
||||||
|
position:absolute; border-radius:50%; pointer-events:none; z-index:1;
|
||||||
|
background: rgba(255,255,255,0.045);
|
||||||
|
animation: qmBobble var(--bdur) ease-in-out infinite;
|
||||||
|
animation-delay: var(--bdelay);
|
||||||
|
}
|
||||||
|
@keyframes qmBobble {
|
||||||
|
0%,100%{ transform:translateY(0) scale(1); opacity:0.5; }
|
||||||
|
50% { transform:translateY(-10px) scale(1.1); opacity:0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Arc banner ── */
|
||||||
|
.qm-arc-banner {
|
||||||
|
position:relative; z-index:5;
|
||||||
|
border-radius:22px; padding: 1.1rem 1.25rem; margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.qm-arc-banner::before {
|
||||||
|
content:''; position:absolute; inset:0;
|
||||||
|
background: repeating-linear-gradient(45deg, transparent, transparent 15px, rgba(255,255,255,0.015) 15px, rgba(255,255,255,0.015) 16px);
|
||||||
|
}
|
||||||
|
.qm-arc-banner-bg-emoji {
|
||||||
|
position:absolute; right:0.5rem; top:50%; transform:translateY(-50%);
|
||||||
|
font-size:5rem; opacity:0.09; filter:blur(2px); pointer-events:none; z-index:0;
|
||||||
|
}
|
||||||
|
.qm-arc-banner-name {
|
||||||
|
font-family:'Sorts Mill Goudy',serif; font-size:1.25rem; font-weight:900; color:white;
|
||||||
|
letter-spacing:0.06em; text-shadow: 0 2px 16px rgba(0,0,0,0.6); position:relative; z-index:1;
|
||||||
|
}
|
||||||
|
.qm-arc-banner-sub {
|
||||||
|
font-family:'Nunito Sans',sans-serif; font-size:0.7rem; font-weight:600;
|
||||||
|
color:rgba(255,255,255,0.5); margin-top:0.2rem; position:relative; z-index:1;
|
||||||
|
}
|
||||||
|
.qm-arc-banner-prog {
|
||||||
|
display:flex; align-items:center; gap:0.65rem; margin-top:0.8rem; position:relative; z-index:1;
|
||||||
|
}
|
||||||
|
.qm-arc-banner-track { flex:1; height:5px; border-radius:100px; background:rgba(255,255,255,0.12); overflow:hidden; }
|
||||||
|
.qm-arc-banner-fill {
|
||||||
|
height:100%; border-radius:100px; background:rgba(255,255,255,0.8);
|
||||||
|
box-shadow:0 0 8px rgba(255,255,255,0.5); transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.qm-arc-banner-count {
|
||||||
|
font-family:'Nunito',sans-serif; font-size:0.68rem; font-weight:900;
|
||||||
|
color:rgba(255,255,255,0.65); white-space:nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ MAP SVG CANVAS ══ */
|
||||||
|
.qm-map-svg { display:block; width:100%; overflow:visible; position:relative; z-index:5; }
|
||||||
|
|
||||||
|
/* ── Info card (foreignObject inside SVG) ── */
|
||||||
|
.qm-info-card {
|
||||||
|
background: rgba(255,255,255,0.055); border:1px solid rgba(255,255,255,0.09);
|
||||||
|
border-radius:16px; padding:0.7rem 0.85rem;
|
||||||
|
backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.qm-info-card.is-claimable { border-color:rgba(251,191,36,0.45); background:rgba(251,191,36,0.07); }
|
||||||
|
.qm-info-card.is-active { border-color:rgba(255,255,255,0.14); }
|
||||||
|
.qm-info-card.is-locked { opacity:0.42; }
|
||||||
|
.qm-info-row1 { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:0.4rem; gap:0.4rem; }
|
||||||
|
.qm-info-title { font-family:'Sorts Mill Goudy',serif; font-size:0.78rem; font-weight:700; color:white; line-height:1.25; }
|
||||||
|
.qm-info-loc { font-size:0.52rem; font-weight:800; letter-spacing:0.14em; text-transform:uppercase; color:var(--arc-accent); margin-bottom:0.12rem; }
|
||||||
|
.qm-xp-badge {
|
||||||
|
display:flex; align-items:center; gap:0.18rem; padding:0.18rem 0.45rem;
|
||||||
|
background:rgba(251,191,36,0.13); border:1px solid rgba(251,191,36,0.3);
|
||||||
|
border-radius:100px; flex-shrink:0;
|
||||||
|
}
|
||||||
|
.qm-xp-badge-val { font-size:0.62rem; font-weight:900; color:#fbbf24; }
|
||||||
|
.qm-prog-track { height:5px; background:rgba(255,255,255,0.08); border-radius:100px; overflow:hidden; margin-bottom:0.22rem; }
|
||||||
|
.qm-prog-fill {
|
||||||
|
height:100%; border-radius:100px;
|
||||||
|
background:linear-gradient(90deg, var(--arc-accent), color-mix(in srgb,var(--arc-accent) 65%,white));
|
||||||
|
box-shadow:0 0 8px color-mix(in srgb,var(--arc-accent) 55%,transparent);
|
||||||
|
transition:width 0.7s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.qm-prog-label { font-family:'Nunito Sans',sans-serif; font-size:0.55rem; font-weight:700; color:rgba(255,255,255,0.38); }
|
||||||
|
.qm-claim-btn {
|
||||||
|
width:100%; margin-top:0.5rem; padding:0.48rem;
|
||||||
|
background:linear-gradient(135deg,#fbbf24,#f59e0b); border:none; border-radius:10px; cursor:pointer;
|
||||||
|
font-family:'Sorts Mill Goudy',serif; font-size:0.72rem; font-weight:700;
|
||||||
|
color:#1a0e00; letter-spacing:0.04em;
|
||||||
|
box-shadow:0 3px 0 #d97706, 0 5px 14px rgba(251,191,36,0.3); transition:all 0.12s ease;
|
||||||
|
}
|
||||||
|
.qm-claim-btn:hover { transform:translateY(-1px); box-shadow:0 5px 0 #d97706; }
|
||||||
|
.qm-claim-btn:active { transform:translateY(1px); box-shadow:0 1px 0 #d97706; }
|
||||||
|
|
||||||
|
/* ══ ARC COMPLETE ══ */
|
||||||
|
.qm-arc-done {
|
||||||
|
position:relative; z-index:5; margin-top:1.5rem; padding:1.25rem; text-align:center;
|
||||||
|
background:linear-gradient(135deg,rgba(251,191,36,0.12),rgba(251,191,36,0.04));
|
||||||
|
border:1px solid rgba(251,191,36,0.3); border-radius:20px;
|
||||||
|
box-shadow:0 0 40px rgba(251,191,36,0.06);
|
||||||
|
}
|
||||||
|
.qm-arc-done-title {
|
||||||
|
font-family:'Sorts Mill Goudy',serif; font-size:1rem; font-weight:900; color:#fbbf24;
|
||||||
|
text-shadow:0 0 20px rgba(251,191,36,0.6); margin-bottom:0.2rem;
|
||||||
|
}
|
||||||
|
.qm-arc-done-sub { font-family:'Nunito Sans',sans-serif; font-size:0.7rem; font-weight:600; color:rgba(251,191,36,0.55); }
|
||||||
|
|
||||||
|
/* ══ FAB ══ */
|
||||||
|
.qm-fab {
|
||||||
|
position:fixed; bottom:calc(1.25rem + 80px + env(safe-area-inset-bottom)); right:1.25rem; z-index:25;
|
||||||
|
width:52px; height:52px; border-radius:50%;
|
||||||
|
background:linear-gradient(135deg,#1a0e45,#3730a3); border:2px solid rgba(251,191,36,0.45);
|
||||||
|
display:flex; align-items:center; justify-content:center; font-size:1.5rem; cursor:pointer;
|
||||||
|
box-shadow:0 6px 24px rgba(0,0,0,0.55), 0 0 0 1px rgba(251,191,36,0.15);
|
||||||
|
animation:qmFabFloat 4s ease-in-out infinite;
|
||||||
|
transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.qm-fab:hover { transform:scale(1.1) rotate(8deg); }
|
||||||
|
.qm-fab:active { transform:scale(0.92); }
|
||||||
|
@keyframes qmFabFloat { 0%,100%{ transform:translateY(0) rotate(-4deg); } 50%{ transform:translateY(-7px) rotate(4deg); } }
|
||||||
|
|
||||||
|
/* ══ NODE ENTRANCE ══ */
|
||||||
|
@keyframes qmIslandIn { from{ opacity:0; transform:scale(0.82) translateY(22px); } to{ opacity:1; transform:scale(1) translateY(0); } }
|
||||||
|
.qm-island-in { animation: qmIslandIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||||
|
const TERRAIN: Record<string, { l: string; m: string; d: string; s: string }> =
|
||||||
|
{
|
||||||
|
east_blue: {
|
||||||
|
l: "#5eead4",
|
||||||
|
m: "#0d9488",
|
||||||
|
d: "#0f766e",
|
||||||
|
s: "rgba(13,148,136,0.55)",
|
||||||
|
},
|
||||||
|
alabasta: {
|
||||||
|
l: "#fcd34d",
|
||||||
|
m: "#d97706",
|
||||||
|
d: "#92400e",
|
||||||
|
s: "rgba(146,64,14,0.65)",
|
||||||
|
},
|
||||||
|
skypiea: {
|
||||||
|
l: "#d8b4fe",
|
||||||
|
m: "#9333ea",
|
||||||
|
d: "#6b21a8",
|
||||||
|
s: "rgba(107,33,168,0.55)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const DECOS: Record<string, [string, string, string]> = {
|
||||||
|
east_blue: ["🌴", "🌿", "🌴"],
|
||||||
|
alabasta: ["🌵", "🏺", "🌵"],
|
||||||
|
skypiea: ["☁️", "✨", "☁️"],
|
||||||
|
};
|
||||||
|
const REQ_ICON: Record<string, string> = {
|
||||||
|
questions: "❓",
|
||||||
|
accuracy: "🎯",
|
||||||
|
streak: "🔥",
|
||||||
|
sessions: "📚",
|
||||||
|
topics: "🗺️",
|
||||||
|
xp: "⚡",
|
||||||
|
leaderboard: "🏆",
|
||||||
|
};
|
||||||
|
const FOAM = Array.from({ length: 22 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
w: 10 + ((i * 17 + 7) % 24),
|
||||||
|
top: `${3 + ((i * 13) % 88)}%`,
|
||||||
|
left: `${(i * 19 + 5) % 96}%`,
|
||||||
|
dur: `${4 + ((i * 7) % 7)}s`,
|
||||||
|
delay: `${(i * 3) % 5}s`,
|
||||||
|
}));
|
||||||
|
const completedCount = (arc: QuestArc) =>
|
||||||
|
arc.nodes.filter((n) => n.status === "completed").length;
|
||||||
|
|
||||||
|
// ─── SVG Island node ──────────────────────────────────────────────────────────
|
||||||
|
const IslandNode = ({
|
||||||
|
node,
|
||||||
|
arcId,
|
||||||
|
accent,
|
||||||
|
index,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
onTap,
|
||||||
|
onClaim,
|
||||||
|
}: {
|
||||||
|
node: QuestNode;
|
||||||
|
arcId: string;
|
||||||
|
accent: string;
|
||||||
|
index: number;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
onTap: (n: QuestNode) => void;
|
||||||
|
onClaim: (n: QuestNode) => void;
|
||||||
|
}) => {
|
||||||
|
const terrain = TERRAIN[arcId] ?? TERRAIN.east_blue;
|
||||||
|
const decos = DECOS[arcId] ?? DECOS.east_blue;
|
||||||
|
|
||||||
|
const isCompleted = node.status === "completed";
|
||||||
|
const isClaimable = node.status === "claimable";
|
||||||
|
const isActive = node.status === "active";
|
||||||
|
const isLocked = node.status === "locked";
|
||||||
|
const pct = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((node.progress / node.requirement.target) * 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hiC = isLocked ? "#4b5563" : isCompleted ? "#6ee7b7" : terrain.l;
|
||||||
|
const midC = isLocked ? "#374151" : isCompleted ? "#10b981" : terrain.m;
|
||||||
|
const loC = isLocked ? "#1f2937" : isCompleted ? "#065f46" : terrain.d;
|
||||||
|
const shdC = isLocked ? "rgba(0,0,0,0.5)" : terrain.s;
|
||||||
|
|
||||||
|
const gradId = `grad-${node.id}`;
|
||||||
|
const clipId = `clip-${node.id}`;
|
||||||
|
const shadowId = `shadow-${node.id}`;
|
||||||
|
const glowId = `glow-${node.id}`;
|
||||||
|
const shapeIdx = index % SHAPES.length;
|
||||||
|
|
||||||
|
const LAND_H = 38;
|
||||||
|
const cardTop = cy + LAND_H + 18;
|
||||||
|
|
||||||
|
const statusCard = isClaimable
|
||||||
|
? "is-claimable"
|
||||||
|
: isActive
|
||||||
|
? "is-active"
|
||||||
|
: isLocked
|
||||||
|
? "is-locked"
|
||||||
|
: "is-completed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
style={{ cursor: isLocked ? "default" : "pointer" }}
|
||||||
|
onClick={() => !isLocked && onTap(node)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={gradId} cx="38%" cy="28%" r="65%">
|
||||||
|
<stop offset="0%" stopColor={hiC} />
|
||||||
|
<stop offset="55%" stopColor={midC} />
|
||||||
|
<stop offset="100%" stopColor={loC} />
|
||||||
|
</radialGradient>
|
||||||
|
<filter id={shadowId} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feDropShadow
|
||||||
|
dx="0"
|
||||||
|
dy="9"
|
||||||
|
stdDeviation="7"
|
||||||
|
floodColor={shdC}
|
||||||
|
floodOpacity="0.8"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="7" result="blur" />
|
||||||
|
<feFlood
|
||||||
|
floodColor={isClaimable ? "#fbbf24" : accent}
|
||||||
|
floodOpacity="0.55"
|
||||||
|
result="col"
|
||||||
|
/>
|
||||||
|
<feComposite in="col" in2="blur" operator="in" result="glow" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="glow" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<clipPath id={clipId}>
|
||||||
|
<g dangerouslySetInnerHTML={{ __html: SHAPES[shapeIdx] }} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Water shimmer halo */}
|
||||||
|
<ellipse
|
||||||
|
cx={cx}
|
||||||
|
cy={cy + LAND_H - 4}
|
||||||
|
rx={isLocked ? 40 : 56}
|
||||||
|
ry={12}
|
||||||
|
fill="rgba(56,189,248,0.22)"
|
||||||
|
style={{ filter: "blur(5px)" }}
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="rx"
|
||||||
|
values={`${isLocked ? 40 : 56};${isLocked ? 46 : 62};${isLocked ? 40 : 56}`}
|
||||||
|
dur="3s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0.6;1;0.6"
|
||||||
|
dur="3s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</ellipse>
|
||||||
|
|
||||||
|
{/* Land shadow blob */}
|
||||||
|
<g
|
||||||
|
transform={`translate(${cx},${cy + 12})`}
|
||||||
|
style={{ filter: "blur(10px)" }}
|
||||||
|
opacity="0.5"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
dangerouslySetInnerHTML={{ __html: SHAPES[shapeIdx] }}
|
||||||
|
style={{ fill: shdC }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Active / claimable glow ring */}
|
||||||
|
{(isActive || isClaimable) && (
|
||||||
|
<g transform={`translate(${cx},${cy}) scale(1.22)`}>
|
||||||
|
<g
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: SHAPES[shapeIdx]
|
||||||
|
.replace(
|
||||||
|
">",
|
||||||
|
` fill="none" stroke="${isClaimable ? "#fbbf24" : accent}" stroke-width="1.8" stroke-dasharray="6 4" opacity="0.6">`,
|
||||||
|
)
|
||||||
|
.replace("<", "<"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Land shape */}
|
||||||
|
<g
|
||||||
|
transform={`translate(${cx},${cy})`}
|
||||||
|
filter={`url(#${isActive || isClaimable ? glowId : shadowId})`}
|
||||||
|
opacity={isLocked ? 0.45 : 1}
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: SHAPES[shapeIdx].replace(">", ` fill="url(#${gradId})">`),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Decorations */}
|
||||||
|
{!isLocked && (
|
||||||
|
<>
|
||||||
|
<text
|
||||||
|
x={cx - 22}
|
||||||
|
y={cy - LAND_H - 6}
|
||||||
|
fontSize="13"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.5))" }}
|
||||||
|
>
|
||||||
|
{decos[0]}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={cx + 22}
|
||||||
|
y={cy - LAND_H - 2}
|
||||||
|
fontSize="15"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.5))" }}
|
||||||
|
>
|
||||||
|
{decos[1]}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pirate flag on active */}
|
||||||
|
{isActive && (
|
||||||
|
<g transform={`translate(${cx - 8},${cy - LAND_H - 26})`}>
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="-20"
|
||||||
|
stroke="#6b4226"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path d="M0,-20 L16,-14 L0,-8Z" fill="#ef4444" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bouncing chest on claimable */}
|
||||||
|
{isClaimable && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy - LAND_H - 8}
|
||||||
|
fontSize="18"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 4px 8px rgba(251,191,36,0.7))" }}
|
||||||
|
>
|
||||||
|
📦
|
||||||
|
<animate
|
||||||
|
attributeName="y"
|
||||||
|
values={`${cy - LAND_H - 8};${cy - LAND_H - 18};${cy - LAND_H - 8}`}
|
||||||
|
dur="1.4s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lock icon */}
|
||||||
|
{isLocked && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy + 6}
|
||||||
|
fontSize="18"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
opacity="0.4"
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quest emoji */}
|
||||||
|
{!isLocked && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy + 6}
|
||||||
|
fontSize="18"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.5))" }}
|
||||||
|
>
|
||||||
|
{node.emoji}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed check */}
|
||||||
|
{isCompleted && (
|
||||||
|
<g transform={`translate(${cx + 40},${cy - LAND_H + 4})`}>
|
||||||
|
<circle
|
||||||
|
r="11"
|
||||||
|
fill="#22c55e"
|
||||||
|
stroke="rgba(255,255,255,0.9)"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
/>
|
||||||
|
<text x="0" y="5" fontSize="12" textAnchor="middle" fill="white">
|
||||||
|
✓
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Island name label */}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy + LAND_H + 10}
|
||||||
|
fontSize="8.5"
|
||||||
|
fontFamily="'Sorts Mill Goudy',serif"
|
||||||
|
fontWeight="700"
|
||||||
|
fill="rgba(255,255,255,0.45)"
|
||||||
|
textAnchor="middle"
|
||||||
|
letterSpacing="0.1em"
|
||||||
|
>
|
||||||
|
{node.islandName?.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Info card via foreignObject */}
|
||||||
|
<foreignObject
|
||||||
|
x={cx - CARD_W / 2}
|
||||||
|
y={cardTop}
|
||||||
|
width={CARD_W}
|
||||||
|
height={CARD_H}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`qm-info-card ${statusCard}`}
|
||||||
|
style={{ ["--arc-accent" as string]: accent }}
|
||||||
|
onClick={() => !isLocked && onTap(node)}
|
||||||
|
>
|
||||||
|
<div className="qm-info-row1">
|
||||||
|
<p className="qm-info-title">{node.title}</p>
|
||||||
|
<div className="qm-xp-badge">
|
||||||
|
<span style={{ fontSize: "0.58rem" }}>⚡</span>
|
||||||
|
<span className="qm-xp-badge-val">+{node.reward.xp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(isActive || isClaimable) && (
|
||||||
|
<>
|
||||||
|
<div className="qm-prog-track">
|
||||||
|
<div className="qm-prog-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="qm-prog-label">
|
||||||
|
{REQ_ICON[node.requirement.type]}
|
||||||
|
{node.progress}/{node.requirement.target}{" "}
|
||||||
|
{node.requirement.label}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isLocked && (
|
||||||
|
<p className="qm-prog-label">
|
||||||
|
🔒 {node.requirement.target} {node.requirement.label} to unlock
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isCompleted && (
|
||||||
|
<p className="qm-prog-label" style={{ color: "#4ade80" }}>
|
||||||
|
✅ Conquered!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isClaimable && (
|
||||||
|
<button
|
||||||
|
className="qm-claim-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClaim(node);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚓ Open Chest
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── SVG Path between two island centres ─────────────────────────────────────
|
||||||
|
const RoutePath = ({
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
done,
|
||||||
|
accent,
|
||||||
|
showShip,
|
||||||
|
}: {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
done: boolean;
|
||||||
|
accent: string;
|
||||||
|
showShip: boolean;
|
||||||
|
}) => {
|
||||||
|
const mx = (x1 + x2) / 2;
|
||||||
|
const my = (y1 + y2) / 2;
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
const perp = 55;
|
||||||
|
const side = x1 < x2 ? 1 : -1;
|
||||||
|
const cpx = mx - (dy / len) * perp * side;
|
||||||
|
const cpy = my + (dx / len) * perp * side;
|
||||||
|
|
||||||
|
const path = `M ${x1} ${y1} Q ${cpx} ${cpy} ${x2} ${y2}`;
|
||||||
|
const shipX = 0.25 * x1 + 0.5 * cpx + 0.25 * x2;
|
||||||
|
const shipY = 0.25 * y1 + 0.5 * cpy + 0.25 * y2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.06)"
|
||||||
|
strokeWidth="18"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={done ? accent : "rgba(255,255,255,0.2)"}
|
||||||
|
strokeWidth={done ? "2.5" : "1.8"}
|
||||||
|
strokeDasharray={done ? "10 6" : "6 8"}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ filter: done ? `drop-shadow(0 0 5px ${accent})` : "none" }}
|
||||||
|
/>
|
||||||
|
{[0.25, 0.5, 0.75].map((t, ti) => {
|
||||||
|
const ex = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * cpx + t * t * x2;
|
||||||
|
const ey = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * cpy + t * t * y2;
|
||||||
|
return (
|
||||||
|
<ellipse
|
||||||
|
key={ti}
|
||||||
|
cx={ex}
|
||||||
|
cy={ey}
|
||||||
|
rx="8"
|
||||||
|
ry="2.5"
|
||||||
|
fill="rgba(255,255,255,0.04)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showShip && (
|
||||||
|
<text
|
||||||
|
x={shipX}
|
||||||
|
y={shipY}
|
||||||
|
fontSize="18"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 3px 6px rgba(0,0,0,0.5))" }}
|
||||||
|
>
|
||||||
|
⛵
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="translate"
|
||||||
|
values="0,0;0,-5;0,0"
|
||||||
|
dur="2.5s"
|
||||||
|
additive="sum"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="rotate"
|
||||||
|
values={`-6,${shipX},${shipY};6,${shipX},${shipY};-6,${shipX},${shipY}`}
|
||||||
|
dur="2.5s"
|
||||||
|
additive="sum"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
export const QuestMap = () => {
|
||||||
|
// ── Store — select ONLY stable primitives/actions, never derived functions ──
|
||||||
|
const arcs = useQuestStore((s) => s.arcs);
|
||||||
|
const activeArcId = useQuestStore((s) => s.activeArcId);
|
||||||
|
const setActiveArc = useQuestStore((s) => s.setActiveArc);
|
||||||
|
const claimNode = useQuestStore((s) => s.claimNode);
|
||||||
|
|
||||||
|
// Derived values — computed from arcs outside the selector, never causes loops
|
||||||
|
const summary = getQuestSummary(arcs);
|
||||||
|
|
||||||
|
// ── Local UI state (doesn't need to be global) ──
|
||||||
|
const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);
|
||||||
|
const [claimingNode, setClaimingNode] = useState<QuestNode | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];
|
||||||
|
const done = completedCount(arc);
|
||||||
|
const pct = Math.round((done / arc.nodes.length) * 100);
|
||||||
|
|
||||||
|
const handleClaim = (node: QuestNode) => setClaimingNode(node);
|
||||||
|
const handleChestClose = () => {
|
||||||
|
if (!claimingNode) return;
|
||||||
|
claimNode(arc.id, claimingNode.id); // store handles state update + next unlock
|
||||||
|
setClaimingNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = arc.nodes;
|
||||||
|
const centres = nodes.map((_, i) => ({
|
||||||
|
x: islandCX(i, arc.id),
|
||||||
|
y: islandCY(i),
|
||||||
|
}));
|
||||||
|
const totalSvgH = svgHeight(nodes.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="qm-screen">
|
||||||
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="qm-header">
|
||||||
|
{/* <p className="qm-page-title">🏴☠️ Treasure Quests</p>
|
||||||
|
<p className="qm-page-sub">Chart your course across the Grand Line</p> */}
|
||||||
|
{/* <div className="qm-stats-strip">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
e: "⚓",
|
||||||
|
v: `${summary.completedNodes}/${summary.totalNodes}`,
|
||||||
|
l: "Quests",
|
||||||
|
},
|
||||||
|
{ e: "⚡", v: `${summary.earnedXP} XP`, l: "Earned" },
|
||||||
|
{ e: "📦", v: `${summary.claimableNodes}`, l: "Chests" },
|
||||||
|
{
|
||||||
|
e: "🏝️",
|
||||||
|
v: `${summary.arcsCompleted}/${summary.totalArcs}`,
|
||||||
|
l: "Arcs",
|
||||||
|
},
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.l} className="qm-stat-chip">
|
||||||
|
<span style={{ fontSize: "0.78rem" }}>{s.e}</span>
|
||||||
|
<span className="qm-stat-val">{s.v}</span>
|
||||||
|
<span className="qm-stat-label">{s.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div> */}
|
||||||
|
<InfoHeader mode="QUEST_EXTENDED" />
|
||||||
|
<div className="qm-arc-tabs">
|
||||||
|
{arcs.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`}
|
||||||
|
style={{ "--arc-accent": a.accentColor } as React.CSSProperties}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveArc(a.id);
|
||||||
|
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.emoji} {a.name}
|
||||||
|
{a.nodes.some((n) => n.status === "claimable") && (
|
||||||
|
<span className="qm-tab-dot" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sea */}
|
||||||
|
<div className="qm-sea-scroll" ref={scrollRef}>
|
||||||
|
<div className="qm-sea">
|
||||||
|
<div className="qm-sea-shimmer" />
|
||||||
|
{FOAM.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="qm-bubble"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: b.w,
|
||||||
|
height: b.w,
|
||||||
|
top: b.top,
|
||||||
|
left: b.left,
|
||||||
|
"--bdur": b.dur,
|
||||||
|
"--bdelay": b.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Arc banner */}
|
||||||
|
<div
|
||||||
|
className="qm-arc-banner"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg,${arc.bgFrom}dd,${arc.bgTo}ee)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="qm-arc-banner-bg-emoji">{arc.emoji}</div>
|
||||||
|
<p className="qm-arc-banner-name">{arc.name}</p>
|
||||||
|
<p className="qm-arc-banner-sub">{arc.subtitle}</p>
|
||||||
|
<div className="qm-arc-banner-prog">
|
||||||
|
<div className="qm-arc-banner-track">
|
||||||
|
<div
|
||||||
|
className="qm-arc-banner-fill"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="qm-arc-banner-count">
|
||||||
|
{done}/{arc.nodes.length} islands
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Single SVG canvas for the whole map ── */}
|
||||||
|
<svg
|
||||||
|
className="qm-map-svg"
|
||||||
|
viewBox={`0 0 ${VW} ${totalSvgH}`}
|
||||||
|
height={totalSvgH}
|
||||||
|
preserveAspectRatio="xMidYMin meet"
|
||||||
|
>
|
||||||
|
{/* Routes drawn FIRST (behind islands) */}
|
||||||
|
{nodes.map((node, i) => {
|
||||||
|
if (i >= nodes.length - 1) return null;
|
||||||
|
const c1 = centres[i];
|
||||||
|
const c2 = centres[i + 1];
|
||||||
|
const ship =
|
||||||
|
node.status === "completed" &&
|
||||||
|
nodes[i + 1]?.status === "active";
|
||||||
|
return (
|
||||||
|
<RoutePath
|
||||||
|
key={`route-${i}`}
|
||||||
|
x1={c1.x}
|
||||||
|
y1={c1.y}
|
||||||
|
x2={c2.x}
|
||||||
|
y2={c2.y}
|
||||||
|
done={node.status === "completed"}
|
||||||
|
accent={arc.accentColor}
|
||||||
|
showShip={ship}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Islands drawn on top */}
|
||||||
|
{nodes.map((node, i) => (
|
||||||
|
<IslandNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
arcId={arc.id}
|
||||||
|
accent={arc.accentColor}
|
||||||
|
index={i}
|
||||||
|
cx={centres[i].x}
|
||||||
|
cy={centres[i].y}
|
||||||
|
onTap={setSelectedNode}
|
||||||
|
onClaim={handleClaim}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Arc complete seal */}
|
||||||
|
{done === nodes.length && (
|
||||||
|
<g transform={`translate(${VW / 2},${totalSvgH - 60})`}>
|
||||||
|
<circle
|
||||||
|
r="42"
|
||||||
|
fill="rgba(251,191,36,0.12)"
|
||||||
|
stroke="rgba(251,191,36,0.5)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeDasharray="8 4"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
r="34"
|
||||||
|
fill="rgba(255,248,200,0.9)"
|
||||||
|
stroke="rgba(180,120,20,0.4)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="0"
|
||||||
|
y="-8"
|
||||||
|
fontFamily="'Cinzel',serif"
|
||||||
|
fontSize="8"
|
||||||
|
fontWeight="900"
|
||||||
|
fill="#92400e"
|
||||||
|
textAnchor="middle"
|
||||||
|
letterSpacing="0.12em"
|
||||||
|
>
|
||||||
|
ARC
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x="0"
|
||||||
|
y="5"
|
||||||
|
fontFamily="'Cinzel',serif"
|
||||||
|
fontSize="8"
|
||||||
|
fontWeight="900"
|
||||||
|
fill="#92400e"
|
||||||
|
textAnchor="middle"
|
||||||
|
letterSpacing="0.12em"
|
||||||
|
>
|
||||||
|
COMPLETE
|
||||||
|
</text>
|
||||||
|
<text x="0" y="19" fontSize="13" textAnchor="middle">
|
||||||
|
⚓
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="qm-fab"
|
||||||
|
onClick={() =>
|
||||||
|
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
🏴☠️
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedNode && (
|
||||||
|
<QuestNodeModal
|
||||||
|
node={selectedNode}
|
||||||
|
arcAccent={arc.accentColor}
|
||||||
|
arcDark={arc.accentDark}
|
||||||
|
arcId={arc.id}
|
||||||
|
nodeIndex={arc.nodes.findIndex((n) => n.id === selectedNode.id)}
|
||||||
|
onClose={() => setSelectedNode(null)}
|
||||||
|
onClaim={() => {
|
||||||
|
setSelectedNode(null);
|
||||||
|
handleClaim(selectedNode);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{claimingNode && (
|
||||||
|
<ChestOpenModal node={claimingNode} onClose={handleChestClose} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,23 +2,17 @@ import { useAuthStore } from "../../stores/authStore";
|
|||||||
import firstTrophy from "../../assets/icons/first_trophy.png";
|
import firstTrophy from "../../assets/icons/first_trophy.png";
|
||||||
import secondTrophy from "../../assets/icons/second_trophy.png";
|
import secondTrophy from "../../assets/icons/second_trophy.png";
|
||||||
import thirdTrophy from "../../assets/icons/third_trophy.png";
|
import thirdTrophy from "../../assets/icons/third_trophy.png";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
// import {
|
import { getRandomColor } from "../../lib/utils";
|
||||||
// Card,
|
|
||||||
// CardHeader,
|
|
||||||
// CardContent,
|
|
||||||
// CardTitle,
|
|
||||||
// CardDescription,
|
|
||||||
// } from "../../components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Avatar,
|
||||||
TabsContent,
|
AvatarFallback,
|
||||||
TabsList,
|
AvatarImage,
|
||||||
TabsTrigger,
|
} from "../../components/ui/avatar";
|
||||||
} from "../../components/ui/tabs";
|
import { Flame, LucideBadgeQuestionMark, Zap, ChevronDown } from "lucide-react";
|
||||||
|
import type { Leaderboard } from "../../types/leaderboard";
|
||||||
import { Button } from "../../components/ui//button";
|
import { api } from "../../utils/api";
|
||||||
|
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -26,118 +20,532 @@ import {
|
|||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../../components/ui/dropdown-menu";
|
} from "../../components/ui/dropdown-menu";
|
||||||
import { formatTimeFilter, getRandomColor } from "../../lib/utils";
|
|
||||||
import {
|
const DOTS = [
|
||||||
Avatar,
|
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
|
||||||
AvatarFallback,
|
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
|
||||||
AvatarImage,
|
{ size: 9, color: "#22c55e", top: "58%", left: "3%", delay: "0.6s" },
|
||||||
} from "../../components/ui/avatar";
|
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
|
||||||
import { Flame, LucideBadgeQuestionMark, Zap } from "lucide-react";
|
{ size: 7, color: "#f43f5e", top: "42%", right: "2%", delay: "0.9s" },
|
||||||
import type { Leaderboard } from "../../types/leaderboard";
|
{ size: 9, color: "#eab308", top: "72%", right: "5%", delay: "0.4s" },
|
||||||
import { api } from "../../utils/api";
|
];
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
|
||||||
import { LeaderboardRowSkeleton } from "../../components/LeaderboardSkeleton";
|
const STYLES = `
|
||||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.rw-screen {
|
||||||
|
height: 100vh;
|
||||||
|
background: #fffbf4;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-sticky-top {
|
||||||
|
position:relative;z-index:2;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Desktop: wider centered layout */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.rw-sticky-top-inner { max-width: var(--content-max); padding: 2rem 2rem 1.25rem; }
|
||||||
|
.rw-scroll-inner { max-width: var(--content-max); }
|
||||||
|
.rw-scroll-area { padding: 1.5rem 2.5rem 10rem; }
|
||||||
|
|
||||||
|
/* Make empty state sit visually centered within larger canvas */
|
||||||
|
.rw-empty { padding: 5rem 1rem; }
|
||||||
|
|
||||||
|
/* Slightly larger island pill on wide screens and rebalance blobs */
|
||||||
|
.rw-island-wrap { max-width: 420px; left:auto; right:calc((100vw - 256px - var(--content-max)) / 2); top:240px; bottom:auto; transform:none; margin-left:150px; }
|
||||||
|
.rw-island-card { gap: 0.75rem; }
|
||||||
|
|
||||||
|
/* Rebalance decorative blobs on wide screens */
|
||||||
|
.rw-blob-1 { left: -120px; top: -100px; width:300px; height:300px; }
|
||||||
|
.rw-blob-2 { left: 6%; bottom: -40px; }
|
||||||
|
.rw-blob-3 { right: -100px; top: 8%; width:260px; height:260px; }
|
||||||
|
.rw-blob-4 { right: 2%; bottom: 6%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@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; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.rw-controls { display:flex;align-items:center;justify-content:space-between;gap:0.5rem; }
|
||||||
|
|
||||||
|
.rw-tabs { display:flex;gap:0.4rem;flex-wrap:wrap; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.rw-list { display:flex;flex-direction:column;gap:0.6rem; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.rw-xp-chip { color:#84cc16; }
|
||||||
|
.rw-q-chip { color:#0891b2; }
|
||||||
|
.rw-fire-chip { color:#ef4444; }
|
||||||
|
|
||||||
|
.rw-skeleton { display:flex;flex-direction:column;gap:0.6rem; }
|
||||||
|
.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;} }
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
.rw-empty {
|
||||||
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
padding:3.5rem 1rem;gap:0.5rem;text-align:center;
|
||||||
|
}
|
||||||
|
.rw-empty-emoji { font-size:3rem;line-height:1; }
|
||||||
|
.rw-empty-title { font-size:1rem;font-weight:800;color:#6b7280;margin:0; }
|
||||||
|
.rw-empty-sub { font-size:0.82rem;font-weight:600;color:#9ca3af;margin:0; }
|
||||||
|
|
||||||
|
/* ── Floating island 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;
|
||||||
|
width:auto;
|
||||||
|
max-width:300px;
|
||||||
|
top:auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
width:100%;
|
||||||
|
box-sizing:border-box;
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/* stat color variants */
|
||||||
|
.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; }
|
||||||
|
.rw-island-stat.q { border-color:#bae6fd; }
|
||||||
|
.rw-island-stat.q .rw-island-stat-val { color:#0891b2; }
|
||||||
|
.rw-island-stat.streak { border-color:#fecaca; }
|
||||||
|
.rw-island-stat.streak .rw-island-stat-val { color:#ef4444; }
|
||||||
|
|
||||||
|
.rw-island-pill {
|
||||||
|
width:100%;
|
||||||
|
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;
|
||||||
|
justify-content:space-between;
|
||||||
|
transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1),box-shadow 0.2s ease,opacity 0.2s ease;
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
/* Visually dim the pill when there's no user rank data to expand */
|
||||||
|
.rw-island-pill.no-data { opacity:0.65;cursor:default; }
|
||||||
|
|
||||||
|
.rw-island-left { display:flex;align-items:center;gap:0.65rem; }
|
||||||
|
|
||||||
|
.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-right { display:flex;align-items:center;gap:0.5rem; }
|
||||||
|
|
||||||
|
.rw-island-metric {
|
||||||
|
display:flex;align-items:center;gap:0.25rem;
|
||||||
|
padding:0.3rem 0.65rem;
|
||||||
|
background:rgba(255,255,255,0.15);
|
||||||
|
border:1.5px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius:100px;
|
||||||
|
}
|
||||||
|
.rw-island-metric-val { font-family:'Nunito',sans-serif;font-size:0.9rem;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); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: "xp", label: "XP", icon: <Zap size={13} /> },
|
||||||
|
{
|
||||||
|
id: "questions",
|
||||||
|
label: "Questions",
|
||||||
|
icon: <LucideBadgeQuestionMark size={13} />,
|
||||||
|
},
|
||||||
|
{ id: "streaks", label: "Streaks", icon: <Flame size={13} /> },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabId = (typeof TABS)[number]["id"];
|
||||||
|
|
||||||
|
const trophies = [firstTrophy, secondTrophy, thirdTrophy];
|
||||||
|
|
||||||
|
// ── Score helpers ─────────────────────────────────────────────────────────────
|
||||||
|
const getScore = (u: Record<string, unknown>, tab: TabId): number | string => {
|
||||||
|
if (tab === "xp") return (u.score ?? "—") as number | string;
|
||||||
|
if (tab === "questions") return (u.score ?? "—") as number | string;
|
||||||
|
if (tab === "streaks") return (u.streak ?? "—") as number | string;
|
||||||
|
|
||||||
|
return "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserScore = (
|
||||||
|
ur: Record<string, unknown> | undefined,
|
||||||
|
tab: TabId,
|
||||||
|
): number | string => {
|
||||||
|
if (!ur) return "—";
|
||||||
|
return getScore(ur, tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Island card config — driven by active tab ─────────────────────────────────
|
||||||
|
const getIslandStats = (
|
||||||
|
ur: Record<string, unknown> | undefined,
|
||||||
|
tab: TabId,
|
||||||
|
) => {
|
||||||
|
const rank = ur?.rank != null ? `#${ur.rank}` : "—";
|
||||||
|
const xp = String(ur?.score ?? ur?.total_xp ?? "—");
|
||||||
|
const level = String(ur?.current_level ?? ur?.level ?? "—");
|
||||||
|
const qs = String(
|
||||||
|
ur?.questions_answered ?? ur?.total_questions ?? ur?.questions ?? "—",
|
||||||
|
);
|
||||||
|
const str = String(ur?.streaks ?? ur?.streak ?? ur?.current_streak ?? "—");
|
||||||
|
|
||||||
|
if (tab === "xp")
|
||||||
|
return [
|
||||||
|
{ cls: "rank", val: rank, label: "Rank" },
|
||||||
|
{ cls: "xp", val: xp, label: "XP" },
|
||||||
|
{ cls: "lvl", val: level, label: "Level" },
|
||||||
|
];
|
||||||
|
if (tab === "questions")
|
||||||
|
return [
|
||||||
|
{ cls: "rank", val: rank, label: "Rank" },
|
||||||
|
{ cls: "q", val: qs, label: "Questions" },
|
||||||
|
{ cls: "lvl", val: level, label: "Level" },
|
||||||
|
];
|
||||||
|
// streaks
|
||||||
|
return [
|
||||||
|
{ cls: "rank", val: rank, label: "Rank" },
|
||||||
|
{ cls: "streak", val: str, label: "Streak" },
|
||||||
|
{ cls: "lvl", val: level, label: "Level" },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyState = () => (
|
||||||
|
<div className="rw-empty">
|
||||||
|
<span className="rw-empty-emoji">🏜️</span>
|
||||||
|
<p className="rw-empty-title">No entries yet</p>
|
||||||
|
<p className="rw-empty-sub">Be the first on the board!</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const Rewards = () => {
|
export const Rewards = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const [time, setTime] = useState("bottom");
|
const [time, setTime] = useState("today");
|
||||||
const [activeTab, setActiveTab] = useState("xp");
|
const [activeTab, setActiveTab] = useState<TabId>("xp");
|
||||||
|
const [leaderboard, setLeaderboard] = useState<Leaderboard | undefined>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [islandOpen, setIslandOpen] = useState(false);
|
||||||
|
const { setUserMetrics } = useExamConfigStore();
|
||||||
|
|
||||||
const [leaderboard, setLeaderboard] = useState<Leaderboard>();
|
const TIME_MAP: Record<string, string> = {
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
today: "daily",
|
||||||
|
week: "weekly",
|
||||||
const { setUserXp } = useExamConfigStore();
|
month: "monthly",
|
||||||
|
alltime: "all_time",
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchLeaderboard = async () => {
|
const fetchData = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const authStorage = localStorage.getItem("auth-storage");
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
if (!authStorage) return;
|
if (!authStorage) return;
|
||||||
|
const {
|
||||||
const parsed = JSON.parse(authStorage) as {
|
state: { token },
|
||||||
state?: { token?: string };
|
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||||
};
|
|
||||||
|
|
||||||
const token = parsed.state?.token;
|
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
try {
|
||||||
const response = await api.fetchLeaderboard(token);
|
setLoading(true);
|
||||||
|
const response = await api.fetchLeaderboard(
|
||||||
|
token,
|
||||||
|
activeTab,
|
||||||
|
TIME_MAP[time] ?? "daily",
|
||||||
|
);
|
||||||
setLeaderboard(response);
|
setLeaderboard(response);
|
||||||
|
// ✅ FIX 1: Guard against null user_rank before accessing its properties
|
||||||
setUserXp(response.user_rank.total_xp);
|
setUserMetrics({
|
||||||
|
xp: response.user_rank?.score ?? 0,
|
||||||
|
questions: 0,
|
||||||
|
streak: response.user_rank?.streak ?? 0,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (error) {
|
|
||||||
setLoading(false);
|
|
||||||
console.error("Error fetching leaderboard: " + error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [user, activeTab, time]);
|
||||||
|
|
||||||
fetchLeaderboard();
|
const metricIcon = (size = 17) =>
|
||||||
}, [user]);
|
activeTab === "xp" ? (
|
||||||
|
<Zap size={size} className="rw-xp-chip" />
|
||||||
|
) : activeTab === "questions" ? (
|
||||||
|
<LucideBadgeQuestionMark size={size} className="rw-q-chip" />
|
||||||
|
) : (
|
||||||
|
<Flame size={size} className="rw-fire-chip" />
|
||||||
|
);
|
||||||
|
|
||||||
const trophies = [firstTrophy, secondTrophy, thirdTrophy];
|
const metricIconWhite = (size = 15) =>
|
||||||
|
activeTab === "xp" ? (
|
||||||
|
<Zap size={size} color="rgba(255,255,255,0.85)" />
|
||||||
|
) : activeTab === "questions" ? (
|
||||||
|
<LucideBadgeQuestionMark size={size} color="rgba(255,255,255,0.85)" />
|
||||||
|
) : (
|
||||||
|
<Flame size={size} color="rgba(255,255,255,0.85)" />
|
||||||
|
);
|
||||||
|
|
||||||
const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) < 3;
|
// ✅ FIX 2: Safely cast user_rank — null becomes undefined so all optional chaining works
|
||||||
|
const ur = (leaderboard?.user_rank ?? undefined) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const islandStats = getIslandStats(ur, activeTab);
|
||||||
|
const userMetric = getUserScore(ur, activeTab);
|
||||||
|
const hasUserRank = ur != null;
|
||||||
|
|
||||||
|
const formatTimeLabel = (t: string) =>
|
||||||
|
({ today: "Today", week: "Week", month: "Month", alltime: "All" })[t] ?? t;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-8 items-start mx-auto sm:px-6 lg:px-8 py-8">
|
<div className="rw-screen">
|
||||||
<header className="flex flex-col items-center h-fit w-full gap-3">
|
<style>{STYLES}</style>
|
||||||
<h1 className="font-satoshi-black text-3xl">Leaderboards</h1>
|
|
||||||
|
<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.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 header */}
|
||||||
|
<div className="rw-sticky-top">
|
||||||
|
<div className="rw-sticky-top-inner">
|
||||||
|
<header className="rw-header rw-anim">
|
||||||
|
<h1 className="rw-title">🏆 Leaderboard</h1>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="animate-pulse">
|
<div
|
||||||
<div className="h-4 w-60 bg-gray-200 rounded" />
|
style={{
|
||||||
</div>
|
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">
|
// ✅ FIX 3: Show a sensible message when user has no rank yet
|
||||||
Don't stop now! You're{" "}
|
<p className="rw-rank-text">
|
||||||
<span className="text-indigo-400">
|
{hasUserRank ? (
|
||||||
#{leaderboard?.user_rank.rank}
|
<>
|
||||||
</span>{" "}
|
You're <span>#{ur.rank}</span> — keep grinding!
|
||||||
in XP.
|
</>
|
||||||
|
) : (
|
||||||
|
"No rank yet — start answering questions!"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<section className="w-full px-7">
|
|
||||||
<Tabs
|
<div className="rw-controls rw-anim rw-anim-1">
|
||||||
value={activeTab}
|
<div className="rw-tabs">
|
||||||
onValueChange={setActiveTab}
|
{TABS.map((tab) => (
|
||||||
defaultValue="xp"
|
<button
|
||||||
className="space-y-6 h-[calc(100vh-250px)] flex flex-col"
|
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">
|
{tab.icon} {tab.label}
|
||||||
<TabsTrigger
|
</button>
|
||||||
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"
|
</div>
|
||||||
>
|
|
||||||
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>
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild className="rounded-full px-6">
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<button className="rw-filter-btn">
|
||||||
variant="outline"
|
{formatTimeLabel(time)} <ChevronDown size={13} />
|
||||||
className="font-satoshi-bold text-sm text-black"
|
</button>
|
||||||
>
|
|
||||||
{formatTimeFilter(time)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="font-satoshi">
|
<DropdownMenuContent
|
||||||
|
style={{ fontFamily: "'Nunito',sans-serif", fontWeight: 700 }}
|
||||||
|
>
|
||||||
<DropdownMenuRadioGroup value={time} onValueChange={setTime}>
|
<DropdownMenuRadioGroup value={time} onValueChange={setTime}>
|
||||||
<DropdownMenuRadioItem value="today">
|
<DropdownMenuRadioItem value="today">
|
||||||
Today
|
Today
|
||||||
@ -154,219 +562,132 @@ export const Rewards = () => {
|
|||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TabsList>
|
</div>
|
||||||
<TabsContent
|
</div>
|
||||||
value="xp"
|
</div>
|
||||||
className="flex-1 overflow-y-auto space-y-6 pb-8"
|
|
||||||
|
{/* 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 ? (
|
{loading ? (
|
||||||
<div className="space-y-6">
|
<SkeletonRows />
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
) : !leaderboard?.top_users?.length ? (
|
||||||
<LeaderboardRowSkeleton key={i} />
|
// ✅ FIX 4: Show empty state when top_users is empty or missing
|
||||||
))}
|
<EmptyState />
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
leaderboard?.top_users.map((user, index) => {
|
leaderboard.top_users.map((u, index) => {
|
||||||
const isTopThree = index < 3;
|
const row = u as Record<string, unknown>;
|
||||||
|
const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`;
|
||||||
|
const score = getScore(row, activeTab);
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={String(row.user_id)} className={rowClass}>
|
||||||
key={user.user_id}
|
<div className="rw-row-left">
|
||||||
className="flex justify-between items-center"
|
<div className="rw-rank-cell">
|
||||||
>
|
{index < 3 ? (
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{isTopThree ? (
|
|
||||||
<img
|
<img
|
||||||
src={trophies[index]}
|
src={trophies[index]}
|
||||||
alt={`trophy_${index + 1}`}
|
alt={`#${index + 1}`}
|
||||||
className="w-12 h-12"
|
style={{ width: 32, height: 32 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="w-12 text-center font-satoshi-bold text-gray-400">
|
<span className="rw-rank-num">{index + 1}</span>
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
<Avatar
|
||||||
<AvatarImage src={user.avatar_url} />
|
className={getRandomColor()}
|
||||||
<AvatarFallback className="text-white font-satoshi-bold">
|
style={{ width: 38, height: 38 }}
|
||||||
{user.name.slice(0, 1).toUpperCase()}
|
>
|
||||||
|
<AvatarImage src={String(row.avatar_url ?? "")} />
|
||||||
|
<AvatarFallback
|
||||||
|
style={{
|
||||||
|
color: "black",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(row.name ?? "?")
|
||||||
|
.slice(0, 1)
|
||||||
|
.toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<span className="rw-user-name">
|
||||||
<p className="font-satoshi-medium text-gray-600">
|
{String(row.name ?? "")}
|
||||||
{user.name}
|
</span>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rw-row-right">
|
||||||
<div className="flex items-center gap-1">
|
<span className="rw-score">{score}</span>
|
||||||
<p className="font-satoshi-medium">{user.total_xp}</p>
|
{metricIcon()}
|
||||||
<Zap size={20} className="text-lime-500 fill-lime-200" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent
|
</div>
|
||||||
value="questions"
|
</div>
|
||||||
className="flex-1 overflow-y-auto space-y-6"
|
|
||||||
>
|
|
||||||
{/* {leaderboard.map((user, index) => {
|
|
||||||
const isTopThree = index < 3;
|
|
||||||
|
|
||||||
return (
|
{/* Floating island */}
|
||||||
|
<div className="rw-island-wrap">
|
||||||
|
{/* Expanded card — stats change with active tab */}
|
||||||
|
<div className={`rw-island-card${islandOpen ? " open" : ""}`}>
|
||||||
|
{islandStats.map((s) => (
|
||||||
|
<div key={s.cls} className={`rw-island-stat ${s.cls}`}>
|
||||||
|
<span className="rw-island-stat-val">{s.val}</span>
|
||||||
|
<span className="rw-island-stat-label">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pill — ✅ FIX 4 cont: disabled + dimmed when no user rank data */}
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
className={[
|
||||||
className="flex justify-between items-center"
|
"rw-island-pill",
|
||||||
|
islandOpen ? "open" : "",
|
||||||
|
!hasUserRank ? "no-data" : "",
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
// Only allow expanding if not loading and user rank data exists
|
||||||
|
if (!loading && hasUserRank) setIslandOpen((o) => !o);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="rw-island-left">
|
||||||
{isTopThree ? (
|
<div className="rw-island-avatar">
|
||||||
<img
|
<span>
|
||||||
src={trophies[index]}
|
{String(ur?.name ?? user?.name ?? "?")
|
||||||
alt={`trophy_${index + 1}`}
|
.slice(0, 1)
|
||||||
className="w-12 h-12"
|
.toUpperCase()}
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="w-12 text-center font-satoshi-bold text-gray-400">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rw-island-info">
|
||||||
<div className="flex items-center gap-1">
|
<span className="rw-island-you">You</span>
|
||||||
<p className="font-satoshi-medium">{user.xp}</p>
|
<span className="rw-island-name">
|
||||||
<Zap size={20} color="darkgreen" />
|
{loading
|
||||||
</div>
|
? "Loading…"
|
||||||
</div>
|
: hasUserRank
|
||||||
);
|
? String(ur.name ?? user?.name ?? "—")
|
||||||
})} */}
|
: (user?.name ?? "Not ranked")}
|
||||||
</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}
|
|
||||||
</span>
|
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<p className="font-satoshi-medium">{user.xp}</p>
|
|
||||||
<Zap size={20} color="darkgreen" />
|
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
|
||||||
|
|
||||||
{/* Avatar */}
|
<div className="rw-island-right">
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-300" />
|
<div className="rw-island-metric">
|
||||||
|
<span className="rw-island-metric-val">{userMetric}</span>
|
||||||
{/* Name */}
|
{metricIconWhite()}
|
||||||
<div className="h-4 w-32 bg-gray-200 rounded" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rw-island-chevron">
|
||||||
{/* XP */}
|
{/* Hide chevron when there's nothing to expand */}
|
||||||
<div className="flex items-center gap-2">
|
{hasUserRank && <ChevronDown size={13} color="white" />}
|
||||||
<div className="h-4 w-10 bg-gray-200 rounded" />
|
|
||||||
<div className="w-5 h-5 rounded bg-gray-200" />
|
|
||||||
</div>
|
</div>
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<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>
|
||||||
|
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,68 +1,267 @@
|
|||||||
import { Outlet, NavLink } from "react-router-dom";
|
import { Outlet, NavLink, useLocation } from "react-router-dom";
|
||||||
import { Home, BookOpen, Award, User, Video } from "lucide-react";
|
import { Home, BookOpen, Award, User, Video, Map } from "lucide-react";
|
||||||
import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar";
|
import { SidebarProvider } from "../../components/ui/sidebar";
|
||||||
import { AppSidebar } from "../../components/AppSidebar";
|
import { AppSidebar } from "../../components/AppSidebar";
|
||||||
|
|
||||||
|
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/quests",
|
||||||
|
icon: Map,
|
||||||
|
label: "Quests",
|
||||||
|
color: "#587ffc",
|
||||||
|
bg: "rgba(53,75,150,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)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Quest dock overrides: dark navy pirate theme ──────────────────────────────
|
||||||
|
// Active color on quests page gets the gold treatment instead of the tab color.
|
||||||
|
const QUEST_NAV_ITEMS = NAV_ITEMS.map((item) =>
|
||||||
|
item.to === "/student/quests"
|
||||||
|
? { ...item, color: "#fbbf24", bg: "rgba(251,191,36,0.15)" }
|
||||||
|
: { ...item, color: "#94a3b8", bg: "rgba(255,255,255,0.08)" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Cinzel:wght@700&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy:ital@0;1&display=swap');
|
||||||
|
|
||||||
|
/* ══ DEFAULT dock (cream frosted glass) ══ */
|
||||||
|
.sl-dock-wrap {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(1.25rem + env(safe-area-inset-bottom));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 20;
|
||||||
|
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;
|
||||||
|
transition:
|
||||||
|
background 0.4s ease,
|
||||||
|
border-color 0.4s ease,
|
||||||
|
box-shadow 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ QUEST dock (dark navy pirate) ══ */
|
||||||
|
.sl-dock-wrap.quest-mode {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(251,191,36,0.05) 30%,
|
||||||
|
rgba(251,191,36,0.1) 50%,
|
||||||
|
rgba(251,191,36,0.15) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: slGoldSweep 3s linear infinite;
|
||||||
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
border: 1.5px solid rgba(251,191,36,0.28);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0,0,0,0.55),
|
||||||
|
0 2px 8px rgba(0,0,0,0.35),
|
||||||
|
0 0 0 1px rgba(251,191,36,0.08),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes slGoldSweep {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* In quest mode, active quest icon gets a gold glow */
|
||||||
|
.quest-mode .sl-dock-item.active .sl-dock-icon {
|
||||||
|
box-shadow: 0 0 12px rgba(251,191,36,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Label ── */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure the dock is hidden on desktop (md and up) */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sl-dock-wrap { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quest mode: active label uses Cinzel for the pirate feel */
|
||||||
|
.quest-mode .sl-dock-item.active .sl-dock-label {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-shadow: 0 0 12px rgba(251,191,36,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quest mode: inactive icons are dimmer ── */
|
||||||
|
.quest-mode .sl-dock-item:not(.active) .sl-dock-icon {
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: opacity 0.2s ease, background 0.25s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.quest-mode .sl-dock-item:not(.active):hover .sl-dock-icon {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure the dock is hidden on desktop (md and up) */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sl-dock-wrap { display: none !important; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export function StudentLayout() {
|
export function StudentLayout() {
|
||||||
const navItems = [
|
const location = useLocation();
|
||||||
{ to: "/student/home", icon: Home, label: "Home" },
|
const isQuestPage = location.pathname.startsWith("/student/quests");
|
||||||
{ to: "/student/practice", icon: BookOpen, label: "Practice" },
|
|
||||||
{ to: "/student/lessons", icon: Video, label: "Lessons" },
|
// Pick the right nav item config based on page
|
||||||
{ to: "/student/rewards", icon: Award, label: "Rewards" },
|
const items = isQuestPage ? QUEST_NAV_ITEMS : NAV_ITEMS;
|
||||||
{ to: "/student/profile", icon: User, label: "Profile" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
<style>{STYLES}</style>
|
||||||
<div className="flex min-h-screen w-full overflow-x-hidden">
|
<div className="flex min-h-screen w-full overflow-x-hidden">
|
||||||
{/* Desktop Sidebar */}
|
{/* Desktop Sidebar */}
|
||||||
|
<div className="hidden md:block">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<SidebarTrigger className="hidden md:block" />
|
{/* Extra bottom padding so content clears the floating dock */}
|
||||||
<main className="flex-1 pb-24 md:pb-0">
|
<main className="flex-1 pb-24 md:pb-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile bottom nav */}
|
{/* ── Floating dock (mobile only) ── */}
|
||||||
<nav className="fixed bottom-0 left-0 right-0 rounded-t-4xl pt-2 bg-white border-t border-gray-200 shadow-4xl z-20 md:hidden">
|
<nav
|
||||||
<div className="max-w-7xl mx-auto px-2">
|
className={`sl-dock-wrap md:hidden${isQuestPage ? " quest-mode" : ""}`}
|
||||||
<div className="flex justify-around items-center">
|
>
|
||||||
{navItems.map((item) => (
|
{items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex flex-col items-center justify-center py-3 px-4 flex-1 transition-all duration-200 font-satoshi tracking-wide ${
|
`sl-dock-item${isActive ? " active" : ""}`
|
||||||
isActive
|
|
||||||
? "text-indigo-600"
|
|
||||||
: "text-gray-500 hover:text-gray-700"
|
|
||||||
}`
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<item.icon
|
<div
|
||||||
size={24}
|
className="sl-dock-icon"
|
||||||
className={`mb-1 transition-transform ${
|
style={{ background: isActive ? item.bg : "transparent" }}
|
||||||
isActive ? "scale-110" : ""
|
|
||||||
}`}
|
|
||||||
strokeWidth={isActive ? 2.5 : 2}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium ${
|
|
||||||
isActive ? "font-semibold" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
<item.icon
|
||||||
|
size={18}
|
||||||
|
strokeWidth={isActive ? 2.5 : 1.75}
|
||||||
|
color={
|
||||||
|
isActive
|
||||||
|
? item.color
|
||||||
|
: isQuestPage
|
||||||
|
? "rgba(255,255,255,0.4)"
|
||||||
|
: "#94a3b8"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="sl-dock-label" style={{ color: item.color }}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@ -5,119 +5,455 @@ import { api } from "../../../utils/api";
|
|||||||
import { ChoiceCard } from "../../../components/ChoiceCard";
|
import { ChoiceCard } from "../../../components/ChoiceCard";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { slideVariants } from "../../../lib/utils";
|
import { slideVariants } from "../../../lib/utils";
|
||||||
import { Loader2 } from "lucide-react";
|
import { ArrowLeft, Loader2, Search, Zap } from "lucide-react";
|
||||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type Step = "topic" | "review";
|
type Step = "topic" | "review";
|
||||||
|
const STEPS: Step[] = ["topic", "review"];
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.dr-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fffbf4;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
|
||||||
|
.dr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:drWobble1 14s ease-in-out infinite; }
|
||||||
|
.dr-blob-2 { width:190px;height:190px;background:#a5f3fc;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:drWobble2 16s ease-in-out infinite; }
|
||||||
|
.dr-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:drWobble1 18s ease-in-out infinite reverse; }
|
||||||
|
.dr-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:drWobble2 12s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes drWobble1 {
|
||||||
|
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 drWobble2 {
|
||||||
|
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);}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:drFloat 7s ease-in-out infinite; }
|
||||||
|
@keyframes drFloat {
|
||||||
|
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||||
|
50%{transform:translateY(-12px) rotate(180deg);}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-inner {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
max-width: 560px; margin: 0 auto;
|
||||||
|
padding: 2rem 1.25rem 8rem;
|
||||||
|
display: flex; flex-direction: column; gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drPopIn {
|
||||||
|
from { opacity:0; transform:scale(0.92) translateY(12px); }
|
||||||
|
to { opacity:1; transform:scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
.dr-anim { animation: drPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||||
|
.dr-anim-1 { animation-delay:0.05s; }
|
||||||
|
.dr-anim-2 { animation-delay:0.1s; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.dr-header-row { display:flex;align-items:center;gap:0.75rem; }
|
||||||
|
.dr-back-btn {
|
||||||
|
width:40px;height:40px;border-radius:50%;
|
||||||
|
background:white;border:2.5px solid #f3f4f6;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
cursor:pointer;box-shadow:0 3px 10px rgba(0,0,0,0.05);
|
||||||
|
transition:all 0.15s ease;flex-shrink:0;
|
||||||
|
}
|
||||||
|
.dr-back-btn:hover { border-color:#a5f3fc;background:#ecfeff; }
|
||||||
|
.dr-back-btn:active { transform:scale(0.9); }
|
||||||
|
.dr-back-btn.hidden { opacity:0;pointer-events:none; }
|
||||||
|
|
||||||
|
.dr-eyebrow {
|
||||||
|
font-size:0.62rem;font-weight:800;letter-spacing:0.16em;
|
||||||
|
text-transform:uppercase;color:#0891b2;
|
||||||
|
display:flex;align-items:center;gap:0.35rem;
|
||||||
|
}
|
||||||
|
.dr-title {
|
||||||
|
font-size:1.75rem;font-weight:900;color:#1e1b4b;
|
||||||
|
letter-spacing:-0.02em;line-height:1.15;
|
||||||
|
}
|
||||||
|
.dr-sub {
|
||||||
|
font-family:'Nunito Sans',sans-serif;
|
||||||
|
font-size:0.82rem;font-weight:600;color:#9ca3af;
|
||||||
|
margin-top:0.2rem;line-height:1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.dr-progress-wrap {
|
||||||
|
background:white;border:2.5px solid #f3f4f6;
|
||||||
|
border-radius:100px;overflow:hidden;height:8px;
|
||||||
|
box-shadow:0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.dr-progress-fill {
|
||||||
|
height:100%;
|
||||||
|
background:linear-gradient(90deg,#22d3ee,#0891b2);
|
||||||
|
border-radius:100px;
|
||||||
|
transition:width 0.5s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.dr-progress-labels {
|
||||||
|
display:flex;justify-content:space-between;
|
||||||
|
font-size:0.6rem;font-weight:800;letter-spacing:0.1em;
|
||||||
|
text-transform:uppercase;color:#d1d5db;
|
||||||
|
margin-top:0.35rem;padding:0 0.1rem;
|
||||||
|
}
|
||||||
|
.dr-progress-labels span.done { color:#0891b2; }
|
||||||
|
|
||||||
|
/* Step card */
|
||||||
|
.dr-step-card {
|
||||||
|
background:white;border:2.5px solid #f3f4f6;
|
||||||
|
border-radius:24px;padding:1.25rem;
|
||||||
|
box-shadow:0 4px 20px rgba(0,0,0,0.05);
|
||||||
|
display:flex;flex-direction:column;gap:1rem;
|
||||||
|
}
|
||||||
|
.dr-step-title {
|
||||||
|
font-size:1rem;font-weight:900;color:#1e1b4b;
|
||||||
|
display:flex;align-items:center;gap:0.5rem;
|
||||||
|
}
|
||||||
|
.dr-step-badge {
|
||||||
|
font-size:0.58rem;font-weight:800;letter-spacing:0.1em;
|
||||||
|
text-transform:uppercase;padding:0.2rem 0.55rem;
|
||||||
|
border-radius:100px;background:#ecfeff;
|
||||||
|
border:2px solid #a5f3fc;color:#0891b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.dr-search-wrap { position:relative; }
|
||||||
|
.dr-search-icon {
|
||||||
|
position:absolute;left:0.85rem;top:50%;
|
||||||
|
transform:translateY(-50%);pointer-events:none;color:#9ca3af;
|
||||||
|
}
|
||||||
|
.dr-search-input {
|
||||||
|
width:100%;padding:0.7rem 1rem 0.7rem 2.5rem;
|
||||||
|
background:#f9fafb;border:2.5px solid #f3f4f6;
|
||||||
|
border-radius:14px;
|
||||||
|
font-family:'Nunito Sans',sans-serif;
|
||||||
|
font-size:0.85rem;font-weight:600;color:#1e1b4b;
|
||||||
|
outline:none;transition:all 0.2s ease;
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
.dr-search-input:focus {
|
||||||
|
border-color:#67e8f9;background:white;
|
||||||
|
box-shadow:0 0 0 3px rgba(8,145,178,0.1);
|
||||||
|
}
|
||||||
|
.dr-search-input::placeholder { color:#9ca3af; }
|
||||||
|
|
||||||
|
/* Topic grid */
|
||||||
|
.dr-topic-grid {
|
||||||
|
display:grid;grid-template-columns:1fr;
|
||||||
|
gap:0.6rem;max-height:380px;overflow-y:auto;
|
||||||
|
padding-right:0.25rem;
|
||||||
|
}
|
||||||
|
@media(min-width:480px){ .dr-topic-grid { grid-template-columns:1fr 1fr; } }
|
||||||
|
|
||||||
|
/* Loading / empty */
|
||||||
|
.dr-loading {
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
gap:0.6rem;padding:2rem;
|
||||||
|
font-size:0.85rem;font-weight:700;color:#9ca3af;
|
||||||
|
}
|
||||||
|
.dr-spinner { animation:drSpin 0.8s linear infinite; }
|
||||||
|
@keyframes drSpin { to { transform:rotate(360deg); } }
|
||||||
|
.dr-empty { text-align:center;padding:2rem;color:#9ca3af;font-size:0.85rem;font-weight:700; }
|
||||||
|
|
||||||
|
/* Review rows */
|
||||||
|
.dr-review-row {
|
||||||
|
display:flex;align-items:flex-start;gap:0.75rem;
|
||||||
|
padding:0.85rem 0;border-bottom:2px solid #f9fafb;
|
||||||
|
}
|
||||||
|
.dr-review-row:last-child { border-bottom:none;padding-bottom:0; }
|
||||||
|
.dr-review-icon {
|
||||||
|
width:34px;height:34px;border-radius:10px;flex-shrink:0;
|
||||||
|
display:flex;align-items:center;justify-content:center;font-size:0.95rem;
|
||||||
|
}
|
||||||
|
.dr-review-label {
|
||||||
|
font-size:0.62rem;font-weight:800;letter-spacing:0.12em;
|
||||||
|
text-transform:uppercase;color:#9ca3af;
|
||||||
|
}
|
||||||
|
.dr-review-value {
|
||||||
|
font-size:0.9rem;font-weight:800;color:#1e1b4b;
|
||||||
|
margin-top:0.1rem;line-height:1.4;
|
||||||
|
}
|
||||||
|
.dr-chip-wrap { display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.35rem; }
|
||||||
|
.dr-chip {
|
||||||
|
background:#ecfeff;border:2px solid #a5f3fc;
|
||||||
|
border-radius:100px;padding:0.2rem 0.65rem;
|
||||||
|
font-size:0.72rem;font-weight:800;color:#0891b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat chips in review */
|
||||||
|
.dr-stat-row { display:flex;gap:0.6rem;margin-top:0.25rem; }
|
||||||
|
.dr-stat {
|
||||||
|
display:flex;flex-direction:column;align-items:center;
|
||||||
|
background:#f0fdff;border:2px solid #a5f3fc;
|
||||||
|
border-radius:14px;padding:0.5rem 0.85rem;flex:1;
|
||||||
|
}
|
||||||
|
.dr-stat-val { font-size:1rem;font-weight:900;color:#0891b2; }
|
||||||
|
.dr-stat-label {
|
||||||
|
font-size:0.58rem;font-weight:800;letter-spacing:0.1em;
|
||||||
|
text-transform:uppercase;color:#67e8f9;margin-top:0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA bar */
|
||||||
|
.dr-cta-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 96px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 5;
|
||||||
|
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.dr-cta-inner {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.dr-inner { max-width: var(--content-max); padding: 3rem 1.5rem 10rem; }
|
||||||
|
.dr-topic-grid { grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
|
||||||
|
.dr-cta-bar { left: var(--sidebar-width); right: 0; }
|
||||||
|
|
||||||
|
/* Align decorative blobs relative to the centered content container */
|
||||||
|
.dr-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 48px); }
|
||||||
|
.dr-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 56px); }
|
||||||
|
.dr-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 12px); }
|
||||||
|
.dr-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 12px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-next-btn {
|
||||||
|
flex:1;padding:0.9rem 1.5rem;
|
||||||
|
background:#0891b2;color:white;border:none;
|
||||||
|
border-radius:100px;cursor:pointer;
|
||||||
|
font-family:'Nunito',sans-serif;font-size:0.92rem;font-weight:900;
|
||||||
|
display:flex;align-items:center;justify-content:center;gap:0.4rem;
|
||||||
|
box-shadow:0 6px 0 #0e7490,0 8px 20px rgba(8,145,178,0.28);
|
||||||
|
transition:transform 0.1s ease,box-shadow 0.1s ease;
|
||||||
|
}
|
||||||
|
.dr-next-btn:hover { transform:translateY(-2px);box-shadow:0 8px 0 #0e7490,0 12px 24px rgba(8,145,178,0.32); }
|
||||||
|
.dr-next-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #0e7490; }
|
||||||
|
.dr-next-btn:disabled {
|
||||||
|
background:#e5e7eb;color:#9ca3af;cursor:not-allowed;
|
||||||
|
box-shadow:0 4px 0 #d1d5db;
|
||||||
|
}
|
||||||
|
.dr-next-btn:disabled:hover { transform:none;box-shadow:0 4px 0 #d1d5db; }
|
||||||
|
|
||||||
|
.dr-start-btn {
|
||||||
|
flex:1;padding:0.9rem 1.5rem;
|
||||||
|
background:linear-gradient(135deg,#22d3ee,#0891b2);color:white;border:none;
|
||||||
|
border-radius:100px;cursor:pointer;
|
||||||
|
font-family:'Nunito',sans-serif;font-size:0.92rem;font-weight:900;
|
||||||
|
display:flex;align-items:center;justify-content:center;gap:0.4rem;
|
||||||
|
box-shadow:0 6px 0 #0e7490,0 8px 20px rgba(8,145,178,0.3);
|
||||||
|
transition:transform 0.1s ease,box-shadow 0.1s ease;
|
||||||
|
}
|
||||||
|
.dr-start-btn:hover { transform:translateY(-2px);box-shadow:0 8px 0 #0e7490,0 12px 24px rgba(8,145,178,0.35); }
|
||||||
|
.dr-start-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #0e7490; }
|
||||||
|
`;
|
||||||
|
|
||||||
export const Drills = () => {
|
export const Drills = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [direction, setDirection] = useState<1 | -1>(1);
|
const [direction, setDirection] = useState<1 | -1>(1);
|
||||||
|
|
||||||
const [topics, setTopics] = useState<Topic[]>([]);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [step, setStep] = useState<Step>("topic");
|
const [step, setStep] = useState<Step>("topic");
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const { storeTopics, setMode, setQuestionCount } = useExamConfigStore();
|
const { storeTopics, setMode, setQuestionCount } = useExamConfigStore();
|
||||||
|
|
||||||
|
const stepIndex = STEPS.indexOf(step);
|
||||||
|
const progressPct = ((stepIndex + 1) / STEPS.length) * 100;
|
||||||
|
|
||||||
const toggleTopic = (topic: Topic) => {
|
const toggleTopic = (topic: Topic) => {
|
||||||
setSelectedTopics((prev) => {
|
setSelectedTopics((prev) =>
|
||||||
const exists = prev.some((t) => t.id === topic.id);
|
prev.some((t) => t.id === topic.id)
|
||||||
|
? prev.filter((t) => t.id !== topic.id)
|
||||||
|
: [...prev, topic],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (exists) {
|
const goNext = () => {
|
||||||
return prev.filter((t) => t.id !== topic.id);
|
setDirection(1);
|
||||||
}
|
setStep("review");
|
||||||
|
};
|
||||||
return [...prev, topic];
|
const goBack = () => {
|
||||||
});
|
setDirection(-1);
|
||||||
|
setStep("topic");
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleStartDrill() {
|
function handleStartDrill() {
|
||||||
if (!user || !topics) return;
|
if (!user || !topics) return;
|
||||||
|
|
||||||
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
|
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAllTopics = async () => {
|
const fetchAllTopics = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const authStorage = localStorage.getItem("auth-storage");
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
if (!authStorage) return;
|
if (!authStorage) return;
|
||||||
|
const {
|
||||||
const parsed = JSON.parse(authStorage) as {
|
state: { token },
|
||||||
state?: { token?: string };
|
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
||||||
};
|
|
||||||
|
|
||||||
const token = parsed.state?.token;
|
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const response = await api.fetchAllTopics(token);
|
const response = await api.fetchAllTopics(token);
|
||||||
setTopics(response);
|
setTopics(response);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load topics:", e);
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load topics. Reason: " + error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAllTopics();
|
fetchAllTopics();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const filteredTopics = topics.filter((t) =>
|
||||||
|
t.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
<div className="dr-screen">
|
||||||
<header className="space-y-2">
|
<style>{STYLES}</style>
|
||||||
<h1 className="font-satoshi-bold text-3xl">Drills</h1>
|
|
||||||
<p className="font-satoshi text-md text-gray-500">
|
{/* Blobs */}
|
||||||
Train your speed and accuracy with our drill-based testing system.
|
<div className="dr-blob dr-blob-1" />
|
||||||
|
<div className="dr-blob dr-blob-2" />
|
||||||
|
<div className="dr-blob dr-blob-3" />
|
||||||
|
<div className="dr-blob dr-blob-4" />
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{DOTS.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="dr-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="dr-inner">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="dr-header-row dr-anim">
|
||||||
|
<button
|
||||||
|
className={`dr-back-btn${step === "topic" ? " hidden" : ""}`}
|
||||||
|
onClick={goBack}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={17} color="#6b7280" />
|
||||||
|
</button>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<p className="dr-eyebrow">
|
||||||
|
<Zap size={11} /> Drills
|
||||||
</p>
|
</p>
|
||||||
</header>
|
<h1 className="dr-title">
|
||||||
<section>
|
{step === "topic" ? "Pick your topics" : "Review & launch"}
|
||||||
<div className="relative overflow-hidden">
|
</h1>
|
||||||
<AnimatePresence mode="wait">
|
<p className="dr-sub">
|
||||||
|
{step === "topic"
|
||||||
|
? "Choose what you want to drill. Speed and accuracy await."
|
||||||
|
: "Everything look good? Time to drill."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="dr-anim dr-anim-1">
|
||||||
|
<div className="dr-progress-wrap">
|
||||||
|
<div
|
||||||
|
className="dr-progress-fill"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="dr-progress-labels">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<span key={s} className={i <= stepIndex ? "done" : ""}>
|
||||||
|
{s === "topic" ? "Topics" : "Review"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div style={{ position: "relative", overflow: "hidden" }}>
|
||||||
|
<AnimatePresence mode="wait" custom={direction}>
|
||||||
|
{/* Step 1 — Topic */}
|
||||||
{step === "topic" && (
|
{step === "topic" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
custom={direction}
|
|
||||||
key="topic"
|
key="topic"
|
||||||
|
custom={direction}
|
||||||
variants={slideVariants}
|
variants={slideVariants}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
className="space-y-4"
|
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-satoshi-bold">Choose a topic</h2>
|
<div className="dr-step-card">
|
||||||
|
<div className="dr-step-title">
|
||||||
|
Choose topics
|
||||||
|
{selectedTopics.length > 0 && (
|
||||||
|
<span className="dr-step-badge">
|
||||||
|
{selectedTopics.length} selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dr-search-wrap">
|
||||||
|
<Search size={15} className="dr-search-icon" />
|
||||||
<input
|
<input
|
||||||
|
className="dr-search-input"
|
||||||
placeholder="Search topics..."
|
placeholder="Search topics..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full rounded-xl border px-4 py-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Loader2
|
|
||||||
size={30}
|
|
||||||
color="purple"
|
|
||||||
className="animate-spin"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="dr-loading">
|
||||||
|
<Loader2
|
||||||
|
size={22}
|
||||||
|
color="#0891b2"
|
||||||
|
className="dr-spinner"
|
||||||
|
/>
|
||||||
|
Loading topics...
|
||||||
|
</div>
|
||||||
|
) : filteredTopics.length === 0 ? (
|
||||||
|
<p className="dr-empty">No topics match "{search}"</p>
|
||||||
) : (
|
) : (
|
||||||
topics
|
<div className="dr-topic-grid">
|
||||||
.filter((t) =>
|
{filteredTopics.map((t) => (
|
||||||
t.name.toLowerCase().includes(search.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map((t) => (
|
|
||||||
<ChoiceCard
|
<ChoiceCard
|
||||||
key={t.id}
|
key={t.id}
|
||||||
label={t.name}
|
label={t.name}
|
||||||
@ -126,87 +462,109 @@ export const Drills = () => {
|
|||||||
selected={selectedTopics.some((st) => st.id === t.id)}
|
selected={selectedTopics.some((st) => st.id === t.id)}
|
||||||
onClick={() => toggleTopic(t)}
|
onClick={() => toggleTopic(t)}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
disabled={selectedTopics.length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
// ✅ STORE
|
|
||||||
storeTopics(selectedTopics.map((t) => t.id)); // ✅ STORE
|
|
||||||
setMode("DRILL"); // ✅ STORE
|
|
||||||
setQuestionCount(7); // ✅ STORE
|
|
||||||
setDirection(1);
|
|
||||||
setStep("review");
|
|
||||||
}}
|
|
||||||
className={`rounded-2xl py-3 px-6 font-satoshi-bold transition
|
|
||||||
${
|
|
||||||
selectedTopics.length === 0
|
|
||||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
|
||||||
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step 2 — Review */}
|
||||||
{step === "review" && (
|
{step === "review" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
custom={direction}
|
|
||||||
key="review"
|
key="review"
|
||||||
|
custom={direction}
|
||||||
variants={slideVariants}
|
variants={slideVariants}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
className="space-y-6"
|
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-satoshi-bold">
|
<div className="dr-step-card">
|
||||||
Review your choices
|
<p className="dr-step-title">Your drill setup</p>
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border p-4 space-y-2 font-satoshi">
|
{/* Topics */}
|
||||||
<p>
|
<div className="dr-review-row">
|
||||||
<strong>Topics:</strong>{" "}
|
<div
|
||||||
{selectedTopics.map((t) => t.name).join(", ")}
|
className="dr-review-icon"
|
||||||
</p>
|
style={{
|
||||||
|
background: "#ecfeff",
|
||||||
|
border: "2px solid #a5f3fc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📚
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="dr-review-label">Topics</p>
|
||||||
|
<div className="dr-chip-wrap">
|
||||||
|
{selectedTopics.map((t) => (
|
||||||
|
<span key={t.id} className="dr-chip">
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="dr-review-row">
|
||||||
|
<div
|
||||||
|
className="dr-review-icon"
|
||||||
|
style={{
|
||||||
|
background: "#ecfeff",
|
||||||
|
border: "2px solid #a5f3fc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚡
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<p className="dr-review-label">Session</p>
|
||||||
|
<div className="dr-stat-row">
|
||||||
|
<div className="dr-stat">
|
||||||
|
<span className="dr-stat-val">7</span>
|
||||||
|
<span className="dr-stat-label">Questions</span>
|
||||||
|
</div>
|
||||||
|
<div className="dr-stat">
|
||||||
|
<span className="dr-stat-val">~5</span>
|
||||||
|
<span className="dr-stat-label">Minutes</span>
|
||||||
|
</div>
|
||||||
|
<div className="dr-stat">
|
||||||
|
<span className="dr-stat-val">⏱️</span>
|
||||||
|
<span className="dr-stat-label">Timed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
disabled={step === "topic"}
|
|
||||||
onClick={() => {
|
|
||||||
const order: Step[] = ["topic", "review"];
|
|
||||||
setDirection(-1);
|
|
||||||
setStep(order[order.indexOf(step) - 1]);
|
|
||||||
}}
|
|
||||||
className={`absolute bottom-24 left-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
|
|
||||||
${
|
|
||||||
step === "topic"
|
|
||||||
? "opacity-0 pointer-events-none"
|
|
||||||
: "bg-linear-to-br from-slate-500 to-slate-600 text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
{/* CTA bar */}
|
||||||
|
<div className="dr-cta-bar">
|
||||||
|
<div className="dr-cta-inner">
|
||||||
|
{step === "topic" && (
|
||||||
<button
|
<button
|
||||||
disabled={step !== "review"}
|
className="dr-next-btn"
|
||||||
className={`absolute bottom-24 right-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
|
disabled={selectedTopics.length === 0}
|
||||||
${
|
|
||||||
step !== "review"
|
|
||||||
? "opacity-0 pointer-events-none"
|
|
||||||
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleStartDrill();
|
storeTopics(selectedTopics.map((t) => t.id));
|
||||||
|
setMode("DRILL");
|
||||||
|
setQuestionCount(7);
|
||||||
|
goNext();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Start Test
|
Next — Review ⚡
|
||||||
</button>
|
</button>
|
||||||
</section>
|
)}
|
||||||
</main>
|
{step === "review" && (
|
||||||
|
<button className="dr-start-btn" onClick={handleStartDrill}>
|
||||||
|
⚡ Start Drill
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import {
|
|||||||
Pilcrow,
|
Pilcrow,
|
||||||
Superscript,
|
Superscript,
|
||||||
WholeWord,
|
WholeWord,
|
||||||
|
Trophy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent } from "../../../components/ui/card";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAuthStore } from "../../../stores/authStore";
|
import { useAuthStore } from "../../../stores/authStore";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@ -14,106 +14,446 @@ import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
|||||||
|
|
||||||
type Module = "EBRW" | "MATH" | null;
|
type Module = "EBRW" | "MATH" | null;
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.htm-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fffbf4;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htm-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
|
||||||
|
.htm-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:htmWobble1 14s ease-in-out infinite; }
|
||||||
|
.htm-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:htmWobble2 16s ease-in-out infinite; }
|
||||||
|
.htm-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:htmWobble1 18s ease-in-out infinite reverse; }
|
||||||
|
.htm-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:htmWobble2 12s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes htmWobble1 {
|
||||||
|
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 htmWobble2 {
|
||||||
|
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);}
|
||||||
|
}
|
||||||
|
|
||||||
|
.htm-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:htmFloat 7s ease-in-out infinite; }
|
||||||
|
@keyframes htmFloat {
|
||||||
|
0%,100%{transform:translateY(0) rotate(0deg);}
|
||||||
|
50%{transform:translateY(-12px) rotate(180deg);}
|
||||||
|
}
|
||||||
|
|
||||||
|
.htm-inner {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
max-width: 560px; margin: 0 auto;
|
||||||
|
padding: 2rem 1.25rem 8rem;
|
||||||
|
display: flex; flex-direction: column; gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes htmPopIn {
|
||||||
|
from { opacity:0; transform:scale(0.92) translateY(12px); }
|
||||||
|
to { opacity:1; transform:scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
.htm-anim { animation: htmPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||||
|
.htm-anim-1 { animation-delay: 0.05s; }
|
||||||
|
.htm-anim-2 { animation-delay: 0.12s; }
|
||||||
|
.htm-anim-3 { animation-delay: 0.19s; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.htm-eyebrow {
|
||||||
|
font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: #84cc16;
|
||||||
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.htm-title {
|
||||||
|
font-size: 1.75rem; font-weight: 900; color: #1e1b4b;
|
||||||
|
letter-spacing: -0.02em; line-height: 1.15;
|
||||||
|
}
|
||||||
|
.htm-sub {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.82rem; font-weight: 600; color: #9ca3af; line-height: 1.5;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Module cards */
|
||||||
|
.htm-card {
|
||||||
|
border-radius: 28px;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
transition: transform 0.2s cubic-bezier(0.34,1.56,0.64,1),
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex; flex-direction: column; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.htm-card:hover { transform: translateY(-4px); }
|
||||||
|
.htm-card:active { transform: translateY(2px) scale(0.98); }
|
||||||
|
|
||||||
|
.htm-card.ebrw {
|
||||||
|
background: linear-gradient(145deg, #3b82f6 0%, #1d4ed8 60%, #1e40af 100%);
|
||||||
|
box-shadow: 0 10px 0 #1e3a8a66, 0 14px 32px rgba(29,78,216,0.3);
|
||||||
|
}
|
||||||
|
.htm-card.ebrw.selected {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 10px 0 #1e3a8a88, 0 16px 40px rgba(29,78,216,0.45);
|
||||||
|
transform: translateY(-4px) scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.htm-card.math {
|
||||||
|
background: linear-gradient(145deg, #f43f5e 0%, #e11d48 60%, #be123c 100%);
|
||||||
|
box-shadow: 0 10px 0 #9f123666, 0 14px 32px rgba(225,29,72,0.3);
|
||||||
|
}
|
||||||
|
.htm-card.math.selected {
|
||||||
|
border-color: #fda4af;
|
||||||
|
box-shadow: 0 10px 0 #9f123688, 0 16px 40px rgba(225,29,72,0.45);
|
||||||
|
transform: translateY(-4px) scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative icons */
|
||||||
|
.htm-card-icons {
|
||||||
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card body (text + chips) */
|
||||||
|
.htm-card-body {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
padding: 1.5rem 1.5rem 1.75rem;
|
||||||
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htm-card-tag {
|
||||||
|
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase; color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.htm-card-name {
|
||||||
|
font-size: 1.6rem; font-weight: 900; color: white;
|
||||||
|
letter-spacing: -0.02em; line-height: 1.1;
|
||||||
|
}
|
||||||
|
.htm-card-desc {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.78rem; font-weight: 600; color: rgba(255,255,255,0.7);
|
||||||
|
line-height: 1.4; margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat pills row */
|
||||||
|
.htm-stat-row {
|
||||||
|
display: flex; gap: 0.4rem; margin-top: 0.5rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.htm-stat-pill {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border: 1.5px solid rgba(255,255,255,0.25);
|
||||||
|
border-radius: 100px; padding: 0.25rem 0.65rem;
|
||||||
|
font-size: 0.65rem; font-weight: 800; color: white;
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected check badge */
|
||||||
|
.htm-check-badge {
|
||||||
|
position: absolute; top: 1rem; right: 1rem; z-index: 3;
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all 0.25s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
opacity: 0; transform: scale(0.7);
|
||||||
|
}
|
||||||
|
.htm-card.selected .htm-check-badge {
|
||||||
|
opacity: 1; transform: scale(1);
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
border-color: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA bar */
|
||||||
|
.htm-cta-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 96px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.htm-cta-bar.hidden {
|
||||||
|
transform: translateY(100%); opacity: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
.htm-cta-inner {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.htm-inner { max-width: var(--content-max); padding: 3rem 1.5rem 10rem; }
|
||||||
|
.htm-cta-bar { left: var(--sidebar-width); right: 0; }
|
||||||
|
.htm-cta-inner { max-width: var(--content-max); margin: 0 auto; }
|
||||||
|
|
||||||
|
/* align blobs to centered content */
|
||||||
|
.htm-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 48px); }
|
||||||
|
.htm-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 56px); }
|
||||||
|
.htm-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 12px); }
|
||||||
|
.htm-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 12px); }
|
||||||
|
|
||||||
|
/* make module cards slightly wider on desktop */
|
||||||
|
.htm-card { min-height: 220px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.htm-start-btn {
|
||||||
|
width: 100%; padding: 0.95rem;
|
||||||
|
background: linear-gradient(135deg, #84cc16, #65a30d);
|
||||||
|
color: white; border: none; border-radius: 100px; cursor: pointer;
|
||||||
|
font-family: 'Nunito', sans-serif; font-size: 0.95rem; font-weight: 900;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
|
box-shadow: 0 6px 0 #3f6212, 0 8px 20px rgba(101,163,13,0.3);
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||||
|
}
|
||||||
|
.htm-start-btn:hover { transform:translateY(-2px); box-shadow:0 8px 0 #3f6212,0 12px 24px rgba(101,163,13,0.35); }
|
||||||
|
.htm-start-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #3f6212; }
|
||||||
|
|
||||||
|
.htm-start-label {
|
||||||
|
font-size: 0.7rem; font-weight: 700; color: #9ca3af;
|
||||||
|
text-align: center; margin-top: 0.5rem;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
}
|
||||||
|
.htm-start-label span { font-weight: 800; color: #6b7280; }
|
||||||
|
`;
|
||||||
|
|
||||||
export const HardTestModules = () => {
|
export const HardTestModules = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selected, setSelected] = useState<Module>(null);
|
const [selected, setSelected] = useState<Module>(null);
|
||||||
|
|
||||||
const { setMode, storeDuration, setSection } = useExamConfigStore();
|
const { setMode, storeDuration, setSection } = useExamConfigStore();
|
||||||
|
|
||||||
function handleStartModule() {
|
function handleStartModule() {
|
||||||
if (!user) return;
|
if (!user || !selected) return;
|
||||||
|
setMode("MODULE");
|
||||||
(setMode("MODULE"), storeDuration(7), setSection(selected));
|
storeDuration(7);
|
||||||
|
setSection(selected);
|
||||||
navigate(`/student/practice/${selected}/test`, { replace: true });
|
navigate(`/student/practice/${selected}/test`, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggle = (mod: "EBRW" | "MATH") =>
|
||||||
|
setSelected((prev) => (prev === mod ? null : mod));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
<div className="htm-screen">
|
||||||
<header className="space-y-2">
|
<style>{STYLES}</style>
|
||||||
<h1 className="font-satoshi-bold text-3xl">Hard Test Modules</h1>
|
|
||||||
<p className="font-satoshi text-md text-gray-500">
|
{/* Blobs */}
|
||||||
Tackle hard practice test modules by selecting a section.
|
<div className="htm-blob htm-blob-1" />
|
||||||
|
<div className="htm-blob htm-blob-2" />
|
||||||
|
<div className="htm-blob htm-blob-3" />
|
||||||
|
<div className="htm-blob htm-blob-4" />
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{DOTS.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="htm-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="htm-inner">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="htm-anim">
|
||||||
|
<p className="htm-eyebrow">
|
||||||
|
<Trophy size={11} /> Hard Modules
|
||||||
|
</p>
|
||||||
|
<h1 className="htm-title">Pick your challenge</h1>
|
||||||
|
<p className="htm-sub">
|
||||||
|
Tackle the hardest SAT questions. Select a section to begin.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<section className="space-y-6">
|
|
||||||
<Card
|
{/* EBRW Card */}
|
||||||
onClick={() =>
|
<div
|
||||||
setSelected((prev) => (prev === "EBRW" ? null : "EBRW"))
|
className={`htm-card ebrw htm-anim htm-anim-1${selected === "EBRW" ? " selected" : ""}`}
|
||||||
}
|
onClick={() => toggle("EBRW")}
|
||||||
className={`relative cursor-pointer overflow-hidden transition
|
|
||||||
${
|
|
||||||
selected === "EBRW"
|
|
||||||
? "ring-2 ring-blue-500 scale-[1.02]"
|
|
||||||
: "hover:scale-[1.01]"
|
|
||||||
}
|
|
||||||
bg-linear-to-br from-blue-400 to-blue-600
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<CardContent className="z-10 flex items-center justify-center py-16 ">
|
{/* Background icons */}
|
||||||
<h1 className="font-satoshi-bold text-2xl text-blue-50">
|
<div className="htm-card-icons">
|
||||||
Reading & Writing
|
|
||||||
</h1>
|
|
||||||
</CardContent>
|
|
||||||
<Languages
|
<Languages
|
||||||
size={250}
|
size={220}
|
||||||
className="absolute -top-5 -right-10 -rotate-23 text-white opacity-30"
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -20,
|
||||||
|
right: -30,
|
||||||
|
transform: "rotate(-20deg)",
|
||||||
|
color: "white",
|
||||||
|
opacity: 0.12,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<WholeWord
|
<WholeWord
|
||||||
size={150}
|
size={130}
|
||||||
className="absolute -top-10 -left-3 rotate-23 text-white opacity-30"
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -15,
|
||||||
|
left: -10,
|
||||||
|
transform: "rotate(15deg)",
|
||||||
|
color: "white",
|
||||||
|
opacity: 0.1,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Pilcrow
|
<Pilcrow
|
||||||
size={150}
|
size={110}
|
||||||
className="absolute -bottom-12 left-8 -rotate-23 text-white opacity-30"
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -20,
|
||||||
|
left: 20,
|
||||||
|
transform: "rotate(-18deg)",
|
||||||
|
color: "white",
|
||||||
|
opacity: 0.1,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</div>
|
||||||
<Card
|
|
||||||
onClick={() =>
|
{/* Check */}
|
||||||
setSelected((prev) => (prev === "MATH" ? null : "MATH"))
|
<div className="htm-check-badge">
|
||||||
}
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
className={`relative cursor-pointer overflow-hidden transition
|
<path
|
||||||
${
|
d="M2.5 7L5.5 10L11.5 4"
|
||||||
selected === "MATH"
|
stroke="white"
|
||||||
? "ring-2 ring-rose-500 scale-[1.02]"
|
strokeWidth="2.2"
|
||||||
: "hover:scale-[1.01]"
|
strokeLinecap="round"
|
||||||
}
|
strokeLinejoin="round"
|
||||||
bg-linear-to-br from-rose-400 to-rose-600
|
/>
|
||||||
`}
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="htm-card-body">
|
||||||
|
<span className="htm-card-tag">Section 1</span>
|
||||||
|
<h2 className="htm-card-name">
|
||||||
|
Reading &
|
||||||
|
<br />
|
||||||
|
Writing
|
||||||
|
</h2>
|
||||||
|
<p className="htm-card-desc">
|
||||||
|
Grammar, vocabulary, comprehension & evidence-based analysis
|
||||||
|
</p>
|
||||||
|
<div className="htm-stat-row">
|
||||||
|
<span className="htm-stat-pill">📖 27 Questions</span>
|
||||||
|
<span className="htm-stat-pill">⏱️ 32 min</span>
|
||||||
|
<span className="htm-stat-pill">🔥 Hard tier</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MATH Card */}
|
||||||
|
<div
|
||||||
|
className={`htm-card math htm-anim htm-anim-2${selected === "MATH" ? " selected" : ""}`}
|
||||||
|
onClick={() => toggle("MATH")}
|
||||||
>
|
>
|
||||||
<CardContent className="z-10 flex items-center justify-center py-16 ">
|
{/* Background icons */}
|
||||||
<h1 className="font-satoshi-bold text-2xl text-blue-50">
|
<div className="htm-card-icons">
|
||||||
Mathematics
|
|
||||||
</h1>
|
|
||||||
</CardContent>
|
|
||||||
<DecimalsArrowRight
|
<DecimalsArrowRight
|
||||||
size={250}
|
size={220}
|
||||||
className="absolute -top-5 -right-10 -rotate-23 text-white opacity-30"
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -20,
|
||||||
|
right: -30,
|
||||||
|
transform: "rotate(-20deg)",
|
||||||
|
color: "white",
|
||||||
|
opacity: 0.12,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Superscript
|
<Superscript
|
||||||
size={150}
|
size={130}
|
||||||
className="absolute -top-10 -left-3 rotate-23 text-white opacity-30"
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -15,
|
||||||
|
left: -10,
|
||||||
|
transform: "rotate(15deg)",
|
||||||
|
color: "white",
|
||||||
|
opacity: 0.1,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Percent
|
<Percent
|
||||||
size={120}
|
size={110}
|
||||||
className="absolute -bottom-5 left-8 -rotate-10 text-white opacity-30"
|
style={{
|
||||||
/>
|
position: "absolute",
|
||||||
</Card>
|
bottom: -15,
|
||||||
</section>
|
left: 20,
|
||||||
{selected && (
|
transform: "rotate(-10deg)",
|
||||||
<div className=" bottom-6 left-0 right-0 flex justify-center z-50">
|
color: "white",
|
||||||
<button
|
opacity: 0.1,
|
||||||
onClick={() => {
|
|
||||||
handleStartModule();
|
|
||||||
}}
|
}}
|
||||||
className="rounded-2xl px-10 py-4 font-satoshi-bold text-lg
|
/>
|
||||||
bg-linear-to-br from-purple-500 to-purple-600 text-white
|
</div>
|
||||||
shadow-xl animate-in slide-in-from-bottom-4"
|
|
||||||
>
|
{/* Check */}
|
||||||
Start Test
|
<div className="htm-check-badge">
|
||||||
</button>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path
|
||||||
|
d="M2.5 7L5.5 10L11.5 4"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="htm-card-body">
|
||||||
|
<span className="htm-card-tag">Section 2</span>
|
||||||
|
<h2 className="htm-card-name">Mathematics</h2>
|
||||||
|
<p className="htm-card-desc">
|
||||||
|
Algebra, advanced math, geometry & data analysis under
|
||||||
|
pressure
|
||||||
|
</p>
|
||||||
|
<div className="htm-stat-row">
|
||||||
|
<span className="htm-stat-pill">🔢 22 Questions</span>
|
||||||
|
<span className="htm-stat-pill">⏱️ 35 min</span>
|
||||||
|
<span className="htm-stat-pill">🔥 Hard tier</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA bar */}
|
||||||
|
<div className={`htm-cta-bar${!selected ? " hidden" : ""}`}>
|
||||||
|
<div className="htm-cta-inner">
|
||||||
|
<button className="htm-start-btn" onClick={handleStartModule}>
|
||||||
|
🏆 Start{" "}
|
||||||
|
{selected === "EBRW"
|
||||||
|
? "Reading & Writing"
|
||||||
|
: selected === "MATH"
|
||||||
|
? "Mathematics"
|
||||||
|
: ""}{" "}
|
||||||
|
Module
|
||||||
|
</button>
|
||||||
|
<p className="htm-start-label">
|
||||||
|
Tap again to <span>deselect</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { api } from "../../../utils/api";
|
import { api } from "../../../utils/api";
|
||||||
import { useAuthStore } from "../../../stores/authStore";
|
import { useAuthStore } from "../../../stores/authStore";
|
||||||
import type { PracticeSheet } from "../../../types/sheet";
|
import type { PracticeSheet } from "../../../types/sheet";
|
||||||
@ -10,223 +10,469 @@ import {
|
|||||||
CarouselItem,
|
CarouselItem,
|
||||||
type CarouselApi,
|
type CarouselApi,
|
||||||
} from "../../../components/ui/carousel";
|
} from "../../../components/ui/carousel";
|
||||||
import { Button } from "../../../components/ui/button";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
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');
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop / wide layout */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.pt-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
|
||||||
|
.pt-stats-row { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|
||||||
|
.pt-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
|
||||||
|
.pt-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
|
||||||
|
.pt-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
|
||||||
|
.pt-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 = () => {
|
export const Pretest = () => {
|
||||||
const { setSheetId, setMode, storeDuration, setQuestionCount } =
|
const { setSheetId, setMode, storeDuration, setQuestionCount } =
|
||||||
useExamConfigStore();
|
useExamConfigStore();
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const { sheetId } = useParams<{ sheetId: string }>();
|
const { sheetId } = useParams<{ sheetId: string }>();
|
||||||
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
|
||||||
const [current, setCurrent] = useState(0);
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
const [practiceSheet, setPracticeSheet] = useState<PracticeSheet | null>(
|
const [practiceSheet, setPracticeSheet] = useState<PracticeSheet | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleStartTest(sheetId: string) {
|
function handleStartTest(id: string) {
|
||||||
if (!sheetId) {
|
if (!id) return;
|
||||||
console.error("Sheet ID is required to start the test.");
|
setSheetId(id);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSheetId(sheetId);
|
|
||||||
setMode("SIMULATION");
|
setMode("SIMULATION");
|
||||||
storeDuration(practiceSheet?.time_limit ?? 0);
|
storeDuration(practiceSheet?.time_limit ?? 0);
|
||||||
setQuestionCount(2);
|
setQuestionCount(2);
|
||||||
|
navigate(`/student/practice/${id}/test`, { replace: true });
|
||||||
navigate(`/student/practice/${sheetId}/test`, { replace: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
async function fetchPracticeSheet(sheetId: string) {
|
async function fetchSheet(id: string) {
|
||||||
const authStorage = localStorage.getItem("auth-storage");
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
if (!authStorage) {
|
if (!authStorage) return;
|
||||||
console.error("authStorage not found in local storage");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const {
|
const {
|
||||||
state: { token },
|
state: { token },
|
||||||
} = JSON.parse(authStorage);
|
} = JSON.parse(authStorage);
|
||||||
if (!token) {
|
if (!token) return;
|
||||||
console.error("Token not found in authStorage");
|
const data = await api.getPracticeSheetById(token, id);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await api.getPracticeSheetById(token, sheetId);
|
|
||||||
setPracticeSheet(data);
|
setPracticeSheet(data);
|
||||||
}
|
}
|
||||||
fetchPracticeSheet(sheetId!);
|
fetchSheet(sheetId!);
|
||||||
}, [sheetId]);
|
}, [sheetId, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!carouselApi) {
|
if (!carouselApi) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCount(carouselApi.scrollSnapList().length);
|
|
||||||
setCurrent(carouselApi.selectedScrollSnap() + 1);
|
setCurrent(carouselApi.selectedScrollSnap() + 1);
|
||||||
carouselApi.on("select", () => {
|
carouselApi.on("select", () =>
|
||||||
setCurrent(carouselApi.selectedScrollSnap() + 1);
|
setCurrent(carouselApi.selectedScrollSnap() + 1),
|
||||||
});
|
);
|
||||||
}, [carouselApi]);
|
}, [carouselApi]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-6">
|
<div className="pt-screen">
|
||||||
<header className="space-y-2">
|
<style>{STYLES}</style>
|
||||||
<h1 className="text-4xl font-satoshi-bold">{practiceSheet?.title}</h1>
|
|
||||||
<p className="text-lg font-satoshi text-gray-700">
|
{/* Blobs */}
|
||||||
{practiceSheet?.description}
|
<div className="pt-blob pt-blob-1" />
|
||||||
</p>
|
<div className="pt-blob pt-blob-2" />
|
||||||
</header>
|
<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 ? (
|
{practiceSheet ? (
|
||||||
<section className="flex flex-col gap-6 rounded-4xl bg-white border p-8">
|
<>
|
||||||
<div className="flex items-center gap-4">
|
<h1 className="pt-title">{practiceSheet.title}</h1>
|
||||||
<Clock size={65} color="black" />
|
<p className="pt-desc">{practiceSheet.description}</p>
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<section className="flex flex-col items-center gap-6 rounded-4xl bg-white border p-8">
|
<>
|
||||||
<div>
|
<div
|
||||||
<Loader size={30} className="transition animate-spin" />
|
style={{
|
||||||
</div>
|
height: "2rem",
|
||||||
</section>
|
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>
|
<CarouselContent>
|
||||||
{practiceSheet ? (
|
{practiceSheet ? (
|
||||||
practiceSheet.modules.length > 0 ? (
|
practiceSheet.modules.length > 0 ? (
|
||||||
practiceSheet.modules.map((module, index) => (
|
practiceSheet.modules.map((module, index) => (
|
||||||
<CarouselItem key={index} className="">
|
<CarouselItem key={index}>
|
||||||
<section className="flex flex-col gap-6 rounded-4xl p-8 bg-white border">
|
<div className="pt-module-card">
|
||||||
<h1 className="text-2xl font-satoshi-bold">
|
<div className="pt-module-header">
|
||||||
|
<span className="pt-section-badge">
|
||||||
Section {Math.floor(index / 2) + 1}
|
Section {Math.floor(index / 2) + 1}
|
||||||
</h1>
|
</span>
|
||||||
<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)" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center items-center">
|
<p className="pt-module-title">{module.title}</p>
|
||||||
<h3 className="text-xl font-satoshi-bold ">
|
<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}
|
{module.duration}
|
||||||
</h3>
|
</span>
|
||||||
<p className="text-md font-satoshi ">Minutes</p>
|
<span className="pt-module-stat-label">Min</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pt-module-stat">
|
||||||
|
<div className="pt-module-stat-icon lime">
|
||||||
|
<CircleQuestionMark size={18} color="#65a30d" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center items-center gap-4">
|
<span className="pt-module-stat-value">
|
||||||
<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 ">
|
|
||||||
{module.questions.length}
|
{module.questions.length}
|
||||||
</h3>
|
</span>
|
||||||
<p className="text-md font-satoshi">Questions</p>
|
<span className="pt-module-stat-label">
|
||||||
|
Questions
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pt-module-stat">
|
||||||
|
<div className="pt-module-stat-icon amber">
|
||||||
|
<Tag size={18} color="#d97706" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center items-center gap-4">
|
<span
|
||||||
<div className="w-fit bg-amber-100 p-2 rounded-full">
|
className="pt-module-stat-value"
|
||||||
<Tag size={30} color="oklch(66.6% 0.179 58.318)" />
|
style={{ fontSize: "0.85rem" }}
|
||||||
</div>
|
>
|
||||||
<div className="flex flex-col justify-center items-center">
|
|
||||||
<h3 className="text-xl font-satoshi-bold ">
|
|
||||||
{module.section}
|
{module.section}
|
||||||
</h3>
|
</span>
|
||||||
<p className="text-md font-satoshi">Type</p>
|
<span className="pt-module-stat-label">Type</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<CarouselItem>
|
<CarouselItem>
|
||||||
<section className="w-full rounded-4xl p-8 bg-red-100 border border-red-300">
|
<div className="pt-error">
|
||||||
<h1 className="text-lg text-center font-satoshi-bold text-red-500">
|
😕 No modules available for this sheet.
|
||||||
No modules available.
|
</div>
|
||||||
</h1>
|
|
||||||
</section>
|
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<CarouselItem>
|
<CarouselItem>
|
||||||
<section className="flex flex-col w-full rounded-4xl p-8 bg-yellow-100 border items-center justify-between gap-4">
|
<div className="pt-loading">
|
||||||
<div>
|
<Loader size={26} className="pt-spinner" color="#a855f7" />
|
||||||
<Loader size={30} className="transition animate-spin" />
|
<p className="pt-loading-text">Loading modules...</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-center text-xl font-satoshi-bold text-yellow-500">
|
|
||||||
Loading...
|
|
||||||
</h1>
|
|
||||||
</section>
|
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
)}
|
)}
|
||||||
</CarouselContent>
|
</CarouselContent>
|
||||||
|
|
||||||
<div className="flex justify-center mt-4">
|
{/* Dot indicator */}
|
||||||
{practiceSheet?.modules.map((_, index) => (
|
{practiceSheet && practiceSheet.modules.length > 1 && (
|
||||||
|
<div className="pt-dots">
|
||||||
|
{practiceSheet.modules.map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={i}
|
||||||
className={`w-2 h-2 mx-1 rounded-full ${
|
className={`pt-dot-ind ${i + 1 === current ? "active" : "inactive"}`}
|
||||||
index + 1 === current ? "bg-indigo-500" : "bg-gray-300"
|
/>
|
||||||
}`}
|
|
||||||
></div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
<section className="w-full rounded-4xl p-8 bg-white border flex flex-col justify-between gap-4">
|
</div>
|
||||||
<h1 className="text-lg font-satoshi-bold ">
|
|
||||||
This practice sheet will help you prepare for the SAT. Take your time
|
{/* ── Encouragement tip ── */}
|
||||||
and do your best!
|
<div className="pt-card pt-anim pt-anim-3">
|
||||||
</h1>
|
<div className="pt-tip">
|
||||||
</section>
|
<span className="pt-tip-emoji">💪</span>
|
||||||
<Button
|
<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!)}
|
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}
|
disabled={!practiceSheet}
|
||||||
>
|
>
|
||||||
{practiceSheet ? (
|
{practiceSheet ? (
|
||||||
"Start Test"
|
<>Start Test →</>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<Loader size={20} className="pt-spinner" />
|
||||||
<Loader size={60} className="transition animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,119 +1,356 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useResults } from "../../../stores/useResults";
|
import { useResults } from "../../../stores/useResults";
|
||||||
import { LucideArrowLeft } from "lucide-react";
|
import { LucideArrowLeft } from "lucide-react";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "../../../components/ui/card";
|
|
||||||
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
|
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||||
|
|
||||||
const XPGainedCard = ({
|
// ─── Shared styles injected once ─────────────────────────────────────────────
|
||||||
results,
|
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?: {
|
|
||||||
xp_gained: number;
|
|
||||||
total_xp: number;
|
|
||||||
current_level_start: number;
|
|
||||||
next_level_threshold: number;
|
|
||||||
current_level: number;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
const [displayXP, setDisplayXP] = useState(0);
|
|
||||||
|
|
||||||
|
:root { --content-max: 1100px; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop / wide layout */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.results-inner { max-width: var(--content-max); padding: 3rem 1.5rem 4rem; }
|
||||||
|
.stats-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
|
||||||
|
.r-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
|
||||||
|
.r-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
|
||||||
|
.r-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
|
||||||
|
.r-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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(() => {
|
useEffect(() => {
|
||||||
if (!results?.xp_gained) return;
|
if (!target) return;
|
||||||
|
let start: number | null = null;
|
||||||
let startTime: number | null = null;
|
const tick = (t: number) => {
|
||||||
const duration = 800;
|
if (!start) start = t;
|
||||||
|
const p = Math.min((t - start) / duration, 1);
|
||||||
const animate = (time: number) => {
|
setVal(Math.floor(p * target));
|
||||||
if (!startTime) startTime = time;
|
if (p < 1) requestAnimationFrame(tick);
|
||||||
const t = Math.min((time - startTime) / duration, 1);
|
|
||||||
setDisplayXP(Math.floor(t * results.xp_gained));
|
|
||||||
if (t < 1) requestAnimationFrame(animate);
|
|
||||||
};
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}, [target]);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
// ─── Floating dots config (shared) ───────────────────────────────────────────
|
||||||
}, [results?.xp_gained]);
|
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 (
|
// ─── Targeted results ─────────────────────────────────────────────────────────
|
||||||
<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 ──────────────────────────────────────────────────
|
|
||||||
const TARGETED_XP = 15;
|
const TARGETED_XP = 15;
|
||||||
const TARGETED_SCORE = 15;
|
const TARGETED_SCORE = 15;
|
||||||
|
|
||||||
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
|
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
|
||||||
const { userXp, setUserXp } = useExamConfigStore();
|
const { userMetrics, setUserMetrics } = useExamConfigStore();
|
||||||
|
const previousXP = userMetrics.xp ?? 0;
|
||||||
// previousXP is whatever the user had before; we add 15 on top
|
|
||||||
const previousXP = userXp ?? 0;
|
|
||||||
const gainedXP = TARGETED_XP;
|
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 levelMinXP = Math.floor(previousXP / 100) * 100;
|
||||||
const levelMaxXP = levelMinXP + 100;
|
const levelMaxXP = levelMinXP + 100;
|
||||||
const currentLevel = Math.floor(previousXP / 100) + 1;
|
const currentLevel = Math.floor(previousXP / 100) + 1;
|
||||||
|
const displayXP = useCountUp(gainedXP);
|
||||||
const [displayXP, setDisplayXP] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let startTime: number | null = null;
|
setUserMetrics({
|
||||||
const duration = 800;
|
xp: previousXP,
|
||||||
const animate = (time: number) => {
|
questions: 0,
|
||||||
if (!startTime) startTime = time;
|
streak: 0,
|
||||||
const t = Math.min((time - startTime) / duration, 1);
|
});
|
||||||
setDisplayXP(Math.floor(t * gainedXP));
|
|
||||||
if (t < 1) requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-10">
|
<div className="results-screen">
|
||||||
<header className="flex gap-4">
|
<style>{STYLES}</style>
|
||||||
<button
|
|
||||||
onClick={onFinish}
|
{/* Blobs */}
|
||||||
className="p-2 rounded-full border border-purple-400 bg-linear-to-br from-purple-400 to-purple-500"
|
<div className="r-blob r-blob-1" />
|
||||||
>
|
<div className="r-blob r-blob-2" />
|
||||||
<LucideArrowLeft size={20} color="white" />
|
<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>
|
</button>
|
||||||
<h1 className="text-3xl font-satoshi-bold">Results</h1>
|
<h1 className="results-title">Results</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Targeted mode badge */}
|
{/* Mode badge */}
|
||||||
<div className="flex items-center gap-2 bg-purple-50 border border-purple-200 rounded-2xl px-4 py-3">
|
<div className="mode-badge">
|
||||||
<span className="text-xl">🎯</span>
|
<span style={{ fontSize: "1.4rem" }}>🎯</span>
|
||||||
<p className="font-satoshi text-purple-700 text-sm">
|
<div>
|
||||||
<strong>Targeted Mode Complete!</strong> You answered all questions
|
<p className="mode-badge-text">Targeted Mode Complete!</p>
|
||||||
correctly.
|
<p className="mode-badge-sub">
|
||||||
|
You answered all questions correctly.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<CircularLevelProgress
|
||||||
previousXP={previousXP}
|
previousXP={previousXP}
|
||||||
gainedXP={gainedXP}
|
gainedXP={gainedXP}
|
||||||
@ -121,45 +358,50 @@ const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
|
|||||||
levelMaxXP={levelMaxXP}
|
levelMaxXP={levelMaxXP}
|
||||||
level={currentLevel}
|
level={currentLevel}
|
||||||
/>
|
/>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{/* Stats grid */}
|
||||||
<CardHeader>
|
<div className="stats-grid">
|
||||||
<CardTitle>XP</CardTitle>
|
<div className="stat-card orange">
|
||||||
<CardDescription>How much did you improve?</CardDescription>
|
<span className="stat-card-label">⚡ XP Gained</span>
|
||||||
<CardAction>
|
<span className="stat-card-value">+{displayXP}</span>
|
||||||
<p className="font-satoshi-medium">+{displayXP} XP</p>
|
<span className="stat-card-sub">experience points</span>
|
||||||
</CardAction>
|
</div>
|
||||||
</CardHeader>
|
<div className="stat-card green">
|
||||||
</Card>
|
<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>
|
{/* Tip card */}
|
||||||
<CardHeader>
|
<div className="tip-card">
|
||||||
<CardTitle>Score</CardTitle>
|
<div className="tip-card-header">
|
||||||
<CardDescription>Total score you achieved.</CardDescription>
|
<span style={{ fontSize: "1.2rem" }}>🚀</span>
|
||||||
<CardAction>
|
<span className="tip-card-title">Keep the momentum going!</span>
|
||||||
<p className="font-satoshi-medium">{TARGETED_SCORE}</p>
|
</div>
|
||||||
</CardAction>
|
<div className="tip-chips">
|
||||||
</CardHeader>
|
<div className="tip-chip">📖 Review mistakes</div>
|
||||||
</Card>
|
<div className="tip-chip">⏱️ Try timed mode</div>
|
||||||
|
<div className="tip-chip">🎯 Next topic</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<button className="done-btn" onClick={onFinish}>
|
||||||
<CardHeader>
|
Done ✓
|
||||||
<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>
|
</button>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -168,40 +410,96 @@ export const Results = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const results = useResults((s) => s.results);
|
const results = useResults((s) => s.results);
|
||||||
const clearResults = useResults((s) => s.clearResults);
|
const clearResults = useResults((s) => s.clearResults);
|
||||||
|
const { setUserMetrics, payload } = useExamConfigStore();
|
||||||
const { setUserXp, payload } = useExamConfigStore();
|
|
||||||
const isTargeted = payload?.mode === "TARGETED";
|
const isTargeted = payload?.mode === "TARGETED";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (results) setUserXp(results?.total_xp);
|
if (results)
|
||||||
|
setUserMetrics({
|
||||||
|
xp: results.total_xp,
|
||||||
|
questions: results.correct_count,
|
||||||
|
streak: 0,
|
||||||
|
});
|
||||||
}, [results]);
|
}, [results]);
|
||||||
|
|
||||||
function handleFinishExam() {
|
function handleFinishExam() {
|
||||||
useExamConfigStore.getState().clearPayload();
|
useExamConfigStore.getState().clearPayload();
|
||||||
clearResults();
|
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 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 (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-10">
|
<div className="results-screen">
|
||||||
<header className="flex gap-4">
|
<style>{STYLES}</style>
|
||||||
<button
|
|
||||||
onClick={() => handleFinishExam()}
|
{/* Blobs */}
|
||||||
className="p-2 rounded-full border border-purple-400 bg-linear-to-br from-purple-400 to-purple-500"
|
<div className="r-blob r-blob-1" />
|
||||||
>
|
<div className="r-blob r-blob-2" />
|
||||||
<LucideArrowLeft size={20} color="white" />
|
<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>
|
</button>
|
||||||
<h1 className="text-3xl font-satoshi-bold">Results</h1>
|
<h1 className="results-title">Results</h1>
|
||||||
</header>
|
</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 && (
|
{results && (
|
||||||
<CircularLevelProgress
|
<CircularLevelProgress
|
||||||
previousXP={previousXP}
|
previousXP={previousXP}
|
||||||
@ -211,41 +509,55 @@ export const Results = () => {
|
|||||||
level={results.current_level}
|
level={results.current_level}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<XPGainedCard results={results} />
|
{/* Stats grid */}
|
||||||
<Card>
|
<div className="stats-grid">
|
||||||
<CardHeader>
|
<div className="stat-card orange">
|
||||||
<CardTitle>Score</CardTitle>
|
<span className="stat-card-label">⚡ XP Gained</span>
|
||||||
<CardDescription>Total score you achieved.</CardDescription>
|
<span className="stat-card-value">+{displayXP}</span>
|
||||||
<CardAction>
|
<span className="stat-card-sub">experience points</span>
|
||||||
<p className="font-satoshi-medium">{results?.score}</p>
|
</div>
|
||||||
</CardAction>
|
<div className="stat-card green">
|
||||||
</CardHeader>
|
<span className="stat-card-label">🎯 Score</span>
|
||||||
</Card>
|
<span className="stat-card-value">{displayScore}</span>
|
||||||
<Card>
|
<span className="stat-card-sub">total points</span>
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle>Accuracy</CardTitle>
|
<div className={`stat-card ${accuracy >= 70 ? "purple" : "blue"}`}>
|
||||||
<CardDescription>How many did you answer correct?</CardDescription>
|
<span className="stat-card-label">✅ Accuracy</span>
|
||||||
<CardAction>
|
<span className="stat-card-value">{accuracy}%</span>
|
||||||
<p className="font-satoshi-medium">
|
<span className="stat-card-sub">
|
||||||
{results && results.total_questions > 0
|
{results?.correct_count ?? 0} of {results?.total_questions ?? 0}{" "}
|
||||||
? `${Math.round(
|
correct
|
||||||
(results.correct_count / results.total_questions) * 100,
|
</span>
|
||||||
)}%`
|
</div>
|
||||||
: "—"}
|
<div className="stat-card blue">
|
||||||
</p>
|
<span className="stat-card-label">❌ Missed</span>
|
||||||
</CardAction>
|
<span className="stat-card-value">
|
||||||
</CardHeader>
|
{results ? results.total_questions - results.correct_count : 0}
|
||||||
</Card>
|
</span>
|
||||||
<Card>
|
<span className="stat-card-sub">questions to review</span>
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle>How do you improve?</CardTitle>
|
</div>
|
||||||
<CardDescription>
|
|
||||||
Your score is good, but you can do better!
|
{/* Tip card */}
|
||||||
</CardDescription>
|
<div className="tip-card">
|
||||||
</CardHeader>
|
<div className="tip-card-header">
|
||||||
</Card>
|
<span style={{ fontSize: "1.2rem" }}>💡</span>
|
||||||
</main>
|
<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
File diff suppressed because it is too large
Load Diff
@ -3,9 +3,15 @@ import { create } from "zustand";
|
|||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { StartExamPayload, ExamMode } from "../types/test";
|
import type { StartExamPayload, ExamMode } from "../types/test";
|
||||||
|
|
||||||
|
type UserMetrics = {
|
||||||
|
xp: number;
|
||||||
|
questions: number;
|
||||||
|
streak: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface ExamConfigState {
|
interface ExamConfigState {
|
||||||
payload: StartExamPayload | null;
|
payload: StartExamPayload | null;
|
||||||
userXp: number;
|
userMetrics: UserMetrics;
|
||||||
|
|
||||||
setSheetId: (id: string) => void;
|
setSheetId: (id: string) => void;
|
||||||
storeTopics: (ids: string[]) => void;
|
storeTopics: (ids: string[]) => void;
|
||||||
@ -15,7 +21,7 @@ interface ExamConfigState {
|
|||||||
storeDuration: (minutes: number) => void;
|
storeDuration: (minutes: number) => void;
|
||||||
setMode: (mode: ExamMode) => void;
|
setMode: (mode: ExamMode) => void;
|
||||||
setSection: (section: string) => void;
|
setSection: (section: string) => void;
|
||||||
setUserXp: (section: number) => void;
|
setUserMetrics: (userMetrics: UserMetrics) => void;
|
||||||
|
|
||||||
clearPayload: () => void;
|
clearPayload: () => void;
|
||||||
}
|
}
|
||||||
@ -24,7 +30,11 @@ export const useExamConfigStore = create<ExamConfigState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
payload: null,
|
payload: null,
|
||||||
userXp: 0,
|
userMetrics: {
|
||||||
|
xp: 0,
|
||||||
|
questions: 0,
|
||||||
|
streak: 0,
|
||||||
|
},
|
||||||
|
|
||||||
setSheetId: (sheet_id) =>
|
setSheetId: (sheet_id) =>
|
||||||
set({
|
set({
|
||||||
@ -48,9 +58,9 @@ export const useExamConfigStore = create<ExamConfigState>()(
|
|||||||
section,
|
section,
|
||||||
} as StartExamPayload,
|
} as StartExamPayload,
|
||||||
}),
|
}),
|
||||||
setUserXp: (userXp) =>
|
setUserMetrics: (userMetrics) =>
|
||||||
set({
|
set({
|
||||||
userXp: userXp,
|
userMetrics: userMetrics,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setDifficulty: (difficulty) =>
|
setDifficulty: (difficulty) =>
|
||||||
|
|||||||
165
src/stores/useQuestStore.ts
Normal file
165
src/stores/useQuestStore.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
import type { QuestArc, QuestNode, NodeStatus } from "../types/quest";
|
||||||
|
import { CREW_RANKS } from "../types/quest";
|
||||||
|
import { QUEST_ARCS } from "../data/questData";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CrewRank {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
emoji: string;
|
||||||
|
xpRequired: number;
|
||||||
|
progressToNext: number; // 0–1 toward next rank
|
||||||
|
next: { label: string; xpRequired: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestSummary {
|
||||||
|
totalNodes: number;
|
||||||
|
completedNodes: number;
|
||||||
|
activeNodes: number;
|
||||||
|
claimableNodes: number;
|
||||||
|
lockedNodes: number;
|
||||||
|
totalXP: number;
|
||||||
|
earnedXP: number;
|
||||||
|
arcsCompleted: number;
|
||||||
|
totalArcs: number;
|
||||||
|
earnedTitles: string[];
|
||||||
|
crewRank: CrewRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store — ONLY raw state + actions, never derived values ───────────────────
|
||||||
|
// Storing functions that return new objects/arrays in Zustand causes infinite
|
||||||
|
// re-render loops because Zustand uses Object.is to detect changes.
|
||||||
|
// All derived values live below as plain helper functions instead.
|
||||||
|
|
||||||
|
interface QuestStore {
|
||||||
|
arcs: QuestArc[];
|
||||||
|
activeArcId: string;
|
||||||
|
setActiveArc: (arcId: string) => void;
|
||||||
|
claimNode: (arcId: string, nodeId: string) => void;
|
||||||
|
syncFromAPI: (arcs: QuestArc[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useQuestStore = create<QuestStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
arcs: QUEST_ARCS,
|
||||||
|
activeArcId: QUEST_ARCS[0].id,
|
||||||
|
|
||||||
|
setActiveArc: (arcId) => set({ activeArcId: arcId }),
|
||||||
|
|
||||||
|
claimNode: (arcId, nodeId) =>
|
||||||
|
set((state) => ({
|
||||||
|
arcs: state.arcs.map((arc) => {
|
||||||
|
if (arc.id !== arcId) return arc;
|
||||||
|
const nodeIdx = arc.nodes.findIndex((n) => n.id === nodeId);
|
||||||
|
if (nodeIdx === -1) return arc;
|
||||||
|
return {
|
||||||
|
...arc,
|
||||||
|
nodes: arc.nodes.map((n, i) => {
|
||||||
|
if (n.id === nodeId)
|
||||||
|
return { ...n, status: "completed" as NodeStatus };
|
||||||
|
if (i === nodeIdx + 1 && n.status === "locked")
|
||||||
|
return { ...n, status: "active" as NodeStatus };
|
||||||
|
return n;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
syncFromAPI: (arcs) => set({ arcs }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "quest-store",
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
arcs: state.arcs,
|
||||||
|
activeArcId: state.activeArcId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Standalone helper functions ──────────────────────────────────────────────
|
||||||
|
// Call these in your components AFTER selecting arcs from the store.
|
||||||
|
// Because they take arcs as an argument (not selected from the store),
|
||||||
|
// they never cause re-render loops.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const arcs = useQuestStore(s => s.arcs);
|
||||||
|
// const summary = getQuestSummary(arcs);
|
||||||
|
// const rank = getCrewRank(arcs);
|
||||||
|
|
||||||
|
export function getEarnedXP(arcs: QuestArc[]): number {
|
||||||
|
return arcs
|
||||||
|
.flatMap((a) => a.nodes)
|
||||||
|
.filter((n) => n.status === "completed")
|
||||||
|
.reduce((sum, n) => sum + n.reward.xp, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCrewRank(arcs: QuestArc[]): CrewRank {
|
||||||
|
const xp = getEarnedXP(arcs);
|
||||||
|
const ladder = [...CREW_RANKS];
|
||||||
|
let idx = 0;
|
||||||
|
for (let i = ladder.length - 1; i >= 0; i--) {
|
||||||
|
if (xp >= ladder[i].xpRequired) {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = ladder[idx];
|
||||||
|
const nextRank = ladder[idx + 1] ?? null;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
progressToNext: nextRank
|
||||||
|
? Math.min(
|
||||||
|
1,
|
||||||
|
(xp - current.xpRequired) /
|
||||||
|
(nextRank.xpRequired - current.xpRequired),
|
||||||
|
)
|
||||||
|
: 1,
|
||||||
|
next: nextRank
|
||||||
|
? { label: nextRank.label, xpRequired: nextRank.xpRequired }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestSummary(arcs: QuestArc[]): QuestSummary {
|
||||||
|
const allNodes = arcs.flatMap((a) => a.nodes);
|
||||||
|
const earnedXP = getEarnedXP(arcs);
|
||||||
|
return {
|
||||||
|
totalNodes: allNodes.length,
|
||||||
|
completedNodes: allNodes.filter((n) => n.status === "completed").length,
|
||||||
|
activeNodes: allNodes.filter((n) => n.status === "active").length,
|
||||||
|
claimableNodes: allNodes.filter((n) => n.status === "claimable").length,
|
||||||
|
lockedNodes: allNodes.filter((n) => n.status === "locked").length,
|
||||||
|
totalXP: allNodes.reduce((s, n) => s + n.reward.xp, 0),
|
||||||
|
earnedXP,
|
||||||
|
arcsCompleted: arcs.filter((a) =>
|
||||||
|
a.nodes.every((n) => n.status === "completed"),
|
||||||
|
).length,
|
||||||
|
totalArcs: arcs.length,
|
||||||
|
earnedTitles: allNodes
|
||||||
|
.filter((n) => n.status === "completed" && n.reward.title)
|
||||||
|
.map((n) => n.reward.title!),
|
||||||
|
crewRank: getCrewRank(arcs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClaimableCount(arcs: QuestArc[]): number {
|
||||||
|
return arcs.flatMap((a) => a.nodes).filter((n) => n.status === "claimable")
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNode(
|
||||||
|
arcs: QuestArc[],
|
||||||
|
nodeId: string,
|
||||||
|
): QuestNode | undefined {
|
||||||
|
return arcs.flatMap((a) => a.nodes).find((n) => n.id === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveArc(arcs: QuestArc[], activeArcId: string): QuestArc {
|
||||||
|
return arcs.find((a) => a.id === activeArcId) ?? arcs[0];
|
||||||
|
}
|
||||||
@ -28,7 +28,7 @@ interface SatExamState {
|
|||||||
getRemainingTime: () => number;
|
getRemainingTime: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BREAK_DURATION = 30; // seconds
|
const BREAK_DURATION = 600; // seconds
|
||||||
|
|
||||||
export const useSatExam = create<SatExamState>()(
|
export const useSatExam = create<SatExamState>()(
|
||||||
persist(
|
persist(
|
||||||
|
|||||||
@ -3,8 +3,9 @@ export type LeaderboardEntry = {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
total_xp: number;
|
score: number;
|
||||||
current_level: number;
|
current_level: number;
|
||||||
|
streak: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Leaderboard {
|
export interface Leaderboard {
|
||||||
|
|||||||
60
src/types/quest.ts
Normal file
60
src/types/quest.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// ─── Quest System Types ───────────────────────────────────────────────────────
|
||||||
|
// Swap dummy data for API responses later — shape stays the same.
|
||||||
|
|
||||||
|
export type RequirementType =
|
||||||
|
| "questions"
|
||||||
|
| "accuracy"
|
||||||
|
| "streak"
|
||||||
|
| "sessions"
|
||||||
|
| "topics"
|
||||||
|
| "xp"
|
||||||
|
| "leaderboard";
|
||||||
|
|
||||||
|
export type NodeStatus = "locked" | "active" | "claimable" | "completed";
|
||||||
|
|
||||||
|
export type RewardItem = "streak_shield" | "xp_boost" | "title";
|
||||||
|
|
||||||
|
export interface QuestReward {
|
||||||
|
xp: number;
|
||||||
|
title?: string; // crew rank title, e.g. "Navigator"
|
||||||
|
item?: RewardItem;
|
||||||
|
itemLabel?: string; // human-readable, e.g. "Streak Shield ×1"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
flavourText: string;
|
||||||
|
islandName: string; // displayed under the node
|
||||||
|
emoji: string; // island character emoji
|
||||||
|
requirement: {
|
||||||
|
type: RequirementType;
|
||||||
|
target: number;
|
||||||
|
label: string; // e.g. "questions answered"
|
||||||
|
};
|
||||||
|
progress: number; // 0 → requirement.target (API will fill this)
|
||||||
|
status: NodeStatus;
|
||||||
|
reward: QuestReward;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestArc {
|
||||||
|
id: string;
|
||||||
|
name: string; // "East Blue", "Alabasta", "Skypiea"
|
||||||
|
subtitle: string; // short flavour line
|
||||||
|
emoji: string;
|
||||||
|
accentColor: string; // CSS color for this arc's theme
|
||||||
|
accentDark: string;
|
||||||
|
bgFrom: string; // gradient start for arc header
|
||||||
|
bgTo: string;
|
||||||
|
nodes: QuestNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Crew Rank ladder (shown on profile / leaderboard) ───────────────────────
|
||||||
|
export const CREW_RANKS = [
|
||||||
|
{ id: "cabin_boy", label: "Cabin Boy", emoji: "⚓", xpRequired: 0 },
|
||||||
|
{ id: "navigator", label: "Navigator", emoji: "🗺️", xpRequired: 500 },
|
||||||
|
{ id: "first_mate", label: "First Mate", emoji: "⚔️", xpRequired: 1500 },
|
||||||
|
{ id: "warlord", label: "Warlord", emoji: "🔱", xpRequired: 3000 },
|
||||||
|
{ id: "emperor", label: "Emperor", emoji: "👑", xpRequired: 6000 },
|
||||||
|
{ id: "pirate_king", label: "Pirate King", emoji: "🏴☠️", xpRequired: 10000 },
|
||||||
|
] as const;
|
||||||
@ -26,6 +26,10 @@ export interface User {
|
|||||||
status: "ACTIVE" | "INACTIVE";
|
status: "ACTIVE" | "INACTIVE";
|
||||||
joined_at: string;
|
joined_at: string;
|
||||||
last_active: string;
|
last_active: string;
|
||||||
|
total_xp: number;
|
||||||
|
current_level: number;
|
||||||
|
next_level_threshold: number;
|
||||||
|
current_level_start: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
@ -224,8 +228,15 @@ class ApiClient {
|
|||||||
return this.authenticatedRequest<Topic>(`/topics/${topicId}`, token);
|
return this.authenticatedRequest<Topic>(`/topics/${topicId}`, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchLeaderboard(token: string): Promise<Leaderboard> {
|
async fetchLeaderboard(
|
||||||
return this.authenticatedRequest<Leaderboard>(`/leaderboard/`, token);
|
token: string,
|
||||||
|
metric: string,
|
||||||
|
timeframe: string,
|
||||||
|
): Promise<Leaderboard> {
|
||||||
|
return this.authenticatedRequest<Leaderboard>(
|
||||||
|
`/leaderboard/?metric=${metric}&timeframe=${timeframe}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchPredictedScore(token: string): Promise<PredictedScore> {
|
async fetchPredictedScore(token: string): Promise<PredictedScore> {
|
||||||
|
|||||||
Reference in New Issue
Block a user