Compare commits

..

4 Commits

16 changed files with 4950 additions and 131 deletions

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

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

344
src/data/questData.ts Normal file
View 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
View 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";
}

32
src/pages/ErrorPage.tsx Normal file
View 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>
);
}

View File

@ -1,23 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { CheckCircle, Flame, Gauge, Play, Search } from "lucide-react"; import { CheckCircle, Play, Search } from "lucide-react";
import { api } from "../../utils/api"; import { api } from "../../utils/api";
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,
AvatarFallback,
AvatarImage,
} from "../../components/ui/avatar";
import {
Drawer,
DrawerContent,
DrawerTrigger,
} from "../../components/ui/drawer";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
// ─── Shared blob/dot background (same as break/results screens) ──────────────── // ─── Shared blob/dot background (same as break/results screens) ────────────────
const DOTS = [ const DOTS = [
@ -77,36 +66,6 @@ const STYLES = `
gap: 1.75rem; gap: 1.75rem;
} }
/* ── Header ── */
.home-header {
display: flex;
align-items: center;
justify-content: space-between;
animation: hPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
}
.home-header-left { display:flex;align-items:center;gap:0.75rem; }
.home-user-name {
font-size: 1.1rem; font-weight: 900; color: #1e1b4b; line-height:1.1;
}
.home-user-role {
font-size: 0.72rem; font-weight: 700; letter-spacing:0.08em;
text-transform: uppercase; color: #a855f7;
}
.home-header-right { display:flex;align-items:center;gap:0.6rem; }
/* Header action chips */
.h-chip {
display: flex; align-items: center; gap: 0.4rem;
background: white; border: 2.5px solid #f3f4f6;
border-radius: 100px; padding: 0.5rem 0.9rem;
box-shadow: 0 3px 10px rgba(0,0,0,0.06);
cursor: pointer; font-size:0.85rem; font-weight:800;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.h-chip:hover { transform:translateY(-2px);box-shadow:0 6px 14px rgba(0,0,0,0.08); }
.h-chip.streak { border-color:#fecaca; background:#fff5f5; color:#ef4444; }
.h-chip.score { border-color:#d9f99d; background:#f7ffe4; color:#65a30d; }
/* ── Section titles ── */ /* ── Section titles ── */
.h-section-title { .h-section-title {
font-size: 1.2rem; font-weight: 900; color: #1e1b4b; font-size: 1.2rem; font-weight: 900; color: #1e1b4b;
@ -342,12 +301,11 @@ const TIPS = [
]; ];
// ─── Main component ─────────────────────────────────────────────────────────── // ─── Main component ───────────────────────────────────────────────────────────
const PAGE_SIZE = 2; 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 { userMetrics } = useExamConfigStore();
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]); const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]); const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
@ -408,15 +366,8 @@ export const Home = () => {
setVisibleCount(PAGE_SIZE); setVisibleCount(PAGE_SIZE);
}; };
const greeting =
new Date().getHours() < 12
? "Good morning"
: new Date().getHours() < 17
? "Good afternoon"
: "Good evening";
return ( return (
<div className="home-screen pb-12"> <div className="home-screen">
<style>{STYLES}</style> <style>{STYLES}</style>
{/* Blobs */} {/* Blobs */}
@ -447,56 +398,10 @@ export const Home = () => {
<div className="home-inner"> <div className="home-inner">
{/* ── Header ── */} {/* ── Header ── */}
<header className="home-header"> <InfoHeader
<div className="home-header-left"> mode="DEFAULT"
<Avatar style={{ width: 48, height: 48 }}> onViewAll={() => navigate("/student/quests")}
<AvatarImage src={user?.avatar_url} /> />
<AvatarFallback
style={{
fontWeight: 900,
fontSize: "1.1rem",
color: "white",
textTransform: "uppercase",
background: "linear-gradient(135deg,#a855f7,#7c3aed)",
}}
>
{user?.name?.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="home-user-name">
{greeting}, {user?.name?.split(" ")[0] || "Student"}
</p>
<p className="home-user-role">
{user?.role === "STUDENT"
? "Student"
: user?.role === "ADMIN"
? "Admin"
: "Teacher"}
</p>
</div>
</div>
<div className="home-header-right">
{/* Streak chip */}
<div className="h-chip streak">
<Flame size={18} style={{ fill: "#fca5a5" }} />
<span>{userMetrics.streak}</span>
</div>
{/* Score chip */}
<Drawer direction="top">
<DrawerTrigger asChild>
<div className="h-chip score">
<Gauge size={18} />
</div>
</DrawerTrigger>
<DrawerContent>
<PredictedScoreCard />
</DrawerContent>
</Drawer>
</div>
</header>
{/* ── Search ── */} {/* ── Search ── */}
<div className="h-search-wrap h-anim h-anim-1"> <div className="h-search-wrap h-anim h-anim-1">

View File

@ -9,6 +9,8 @@ import {
} from "lucide-react"; } from "lucide-react";
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 = [ const DOTS = [
{ size: 10, color: "#f97316", top: "8%", left: "5%", delay: "0s" }, { size: 10, color: "#f97316", top: "8%", left: "5%", delay: "0s" },
@ -256,10 +258,9 @@ const MODE_CARDS = [
export const Practice = () => { export const Practice = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const userXp = useExamConfigStore.getState().userXp;
return ( return (
<div className="pr-screen pb-12"> <div className="pr-screen">
<style>{STYLES}</style> <style>{STYLES}</style>
{/* Blobs */} {/* Blobs */}
@ -290,15 +291,7 @@ export const Practice = () => {
<div className="pr-inner"> <div className="pr-inner">
{/* ── Header ── */} {/* ── Header ── */}
<header className="pr-header"> <InfoHeader mode="LEVEL" />
<div className="pr-logo-btn">
<BookOpen size={20} color="white" />
</div>
<div className="pr-xp-chip">
<span> {userXp} XP</span>
</div>
</header>
{/* ── Hero banner ── */} {/* ── Hero banner ── */}
<div className="pr-hero pr-anim pr-anim-1"> <div className="pr-hero pr-anim pr-anim-1">
<div className="pr-hero-icon-bg"> <div className="pr-hero-icon-bg">

View 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]}&nbsp;
{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>
);
};

View File

@ -1,5 +1,5 @@
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 } from "../../components/ui/sidebar"; import { SidebarProvider } from "../../components/ui/sidebar";
import { AppSidebar } from "../../components/AppSidebar"; import { AppSidebar } from "../../components/AppSidebar";
@ -18,6 +18,13 @@ const NAV_ITEMS = [
color: "#a855f7", color: "#a855f7",
bg: "rgba(168,85,247,0.12)", 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", to: "/student/lessons",
icon: Video, icon: Video,
@ -41,19 +48,26 @@ const NAV_ITEMS = [
}, },
]; ];
const STYLES = ` // ── Quest dock overrides: dark navy pirate theme ──────────────────────────────
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap'); // 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)" },
);
/* ── The floating island dock ── */ 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 { .sl-dock-wrap {
position: fixed; position: fixed;
bottom: calc(1.25rem + env(safe-area-inset-bottom)); bottom: calc(1.25rem + env(safe-area-inset-bottom));
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 20; z-index: 20;
background: rgba(255,251,244,0.72);
/* Frosted pill */
background: rgba(255, 251, 244, 0.72);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1.5px solid rgba(255,255,255,0.7); border: 1.5px solid rgba(255,255,255,0.7);
@ -62,11 +76,42 @@ const STYLES = `
0 8px 32px rgba(0,0,0,0.12), 0 8px 32px rgba(0,0,0,0.12),
0 2px 8px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.06),
inset 0 1px 0 rgba(255,255,255,0.8); inset 0 1px 0 rgba(255,255,255,0.8);
padding: 0.45rem 0.5rem; padding: 0.45rem 0.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.15rem; 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 ── */ /* ── Each nav item ── */
@ -83,10 +128,11 @@ const STYLES = `
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
transition: transition:
padding 0.35s cubic-bezier(0.34,1.56,0.64,1), padding 0.35s cubic-bezier(0.34,1.56,0.64,1),
gap 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; background 0.25s ease;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
position: relative;
} }
.sl-dock-item:active { transform: scale(0.91); } .sl-dock-item:active { transform: scale(0.91); }
.sl-dock-item.active { .sl-dock-item.active {
@ -102,11 +148,14 @@ const STYLES = `
background: transparent; background: transparent;
transition: background 0.25s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1); transition: background 0.25s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
} }
.sl-dock-item.active .sl-dock-icon { .sl-dock-item.active .sl-dock-icon { transform: scale(1.1); }
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 (only visible when active) ── */ /* ── Label ── */
.sl-dock-label { .sl-dock-label {
font-family: 'Nunito', sans-serif; font-family: 'Nunito', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
@ -129,9 +178,37 @@ const STYLES = `
@media (min-width: 768px) { @media (min-width: 768px) {
.sl-dock-wrap { display: none !important; } .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 location = useLocation();
const isQuestPage = location.pathname.startsWith("/student/quests");
// Pick the right nav item config based on page
const items = isQuestPage ? QUEST_NAV_ITEMS : NAV_ITEMS;
return ( return (
<SidebarProvider> <SidebarProvider>
<style>{STYLES}</style> <style>{STYLES}</style>
@ -148,9 +225,11 @@ export function StudentLayout() {
</main> </main>
</div> </div>
{/* ── Floating island dock (mobile only) ── */} {/* ── Floating dock (mobile only) ── */}
<nav className="sl-dock-wrap md:hidden"> <nav
{NAV_ITEMS.map((item) => ( className={`sl-dock-wrap md:hidden${isQuestPage ? " quest-mode" : ""}`}
>
{items.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@ -167,7 +246,13 @@ export function StudentLayout() {
<item.icon <item.icon
size={18} size={18}
strokeWidth={isActive ? 2.5 : 1.75} strokeWidth={isActive ? 2.5 : 1.75}
color={isActive ? item.color : "#94a3b8"} color={
isActive
? item.color
: isQuestPage
? "rgba(255,255,255,0.4)"
: "#94a3b8"
}
/> />
</div> </div>
<span className="sl-dock-label" style={{ color: item.color }}> <span className="sl-dock-label" style={{ color: item.color }}>

165
src/stores/useQuestStore.ts Normal file
View 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; // 01 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];
}

60
src/types/quest.ts Normal file
View 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;

View File

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