diff --git a/src/App.tsx b/src/App.tsx index febc8e6..4b89997 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,8 @@ import { TargetedPractice } from "./pages/student/targeted-practice/page"; import { Drills } from "./pages/student/drills/page"; import { HardTestModules } from "./pages/student/hard-test-modules/page"; import { Analytics } from "./pages/student/Analytics"; +import { QuestMap } from "./pages/student/QuestMap"; +import ErrorPage from "./pages/ErrorPage"; function App() { const router = createBrowserRouter([ @@ -58,6 +60,10 @@ function App() { path: "analytics", element: , }, + { + path: "quests", + element: , + }, { path: "practice/:sheetId", element: , diff --git a/src/components/ChestOpenModal.tsx b/src/components/ChestOpenModal.tsx new file mode 100644 index 0000000..a899c00 --- /dev/null +++ b/src/components/ChestOpenModal.tsx @@ -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("idle"); + const [showXP, setShowXP] = useState(false); + const timerRef = useRef | 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 ( +
+ + + {/* Background */} +
+ + {/* Stars */} + {STARS.map((s) => ( +
+ ))} + + {/* Crepuscular rays (appear on open) */} + {(phase === "opening" || phase === "revealed") && ( +
+ {RAYS.map((r) => ( +
+ ))} +
+ )} + + {/* Burst rings */} + {(phase === "opening" || phase === "revealed") && ( +
+ {BURST_RINGS.map((r) => ( +
+ ))} +
+ )} + + {/* Particle explosion */} + {(phase === "opening" || phase === "revealed") && ( + <> + {particles.map((p) => ( +
+ ))} + {coins.map((c) => ( +
+ {c.emoji} +
+ ))} + + )} + + {/* Floating sparkles in revealed state */} + {phase === "revealed" && + SPARKLES.map((sp) => ( +
+ {sp.emoji} +
+ ))} + + {/* XP blast */} + {showXP &&
+{node.reward.xp} XP
} + + {/* Card */} +
e.stopPropagation()}> +
+

+ {phase === "revealed" ? "⚓ Treasure Claimed" : "📦 Treasure Chest"} +

+ + {/* Chest */} +
+ {phase !== "revealed" &&
} + {phase !== "revealed" && ( +
+
+
+ )} + {chestEmoji} +
+ + {/* Phase content */} + {phase === "idle" && ( + <> +

Tap to Open!

+

YOUR HARD WORK HAS PAID OFF, PIRATE

+ + )} + {phase === "shaking" && ( + <> +

The chest stirs...

+

⚡ ⚡ ⚡

+ + )} + {phase === "revealed" && ( + <> +

⚓ Spoils of Victory

+
+ {rewards.map((r) => ( +
+ {r.icon} +
+

{r.lbl}

+

{r.val}

+
+
+ ))} +
+ + + )} +
+
+ + {/* Skip link for impatient pirates */} + {phase === "revealed" && ( +

+ tap anywhere to continue +

+ )} +
+ ); +}; diff --git a/src/components/QuestNodeModal.tsx b/src/components/QuestNodeModal.tsx new file mode 100644 index 0000000..7a56a5e --- /dev/null +++ b/src/components/QuestNodeModal.tsx @@ -0,0 +1,1076 @@ +import { useEffect, useState } from "react"; +import { X, Lock } from "lucide-react"; +import type { QuestNode } from "../types/quest"; + +// ─── 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'); + + /* ══ OVERLAY ══ */ + .qnm-overlay { + position: fixed; inset: 0; z-index: 40; + background: rgba(4,8,20,0.72); + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + display: flex; align-items: flex-end; justify-content: center; + animation: qnmFade 0.22s ease both; + } + @keyframes qnmFade { from{opacity:0} to{opacity:1} } + + /* ══ SHEET ══ */ + .qnm-sheet { + width: 100%; max-width: 520px; + background: #06101f; + border-radius: 28px 28px 0 0; + border-top: 1.5px solid rgba(251,191,36,0.2); + box-shadow: 0 -12px 60px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.06); + overflow: hidden; + display: flex; flex-direction: column; + max-height: 92vh; + animation: qnmUp 0.38s cubic-bezier(0.34,1.56,0.64,1) both; + } + @keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} } + + /* Handle */ + .qnm-handle-row { display:flex; justify-content:center; padding:0.8rem 0 0.3rem; flex-shrink:0; } + .qnm-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); } + + /* Close btn */ + .qnm-close { + position:absolute; top:0.9rem; right:1.1rem; z-index:10; + width:30px; height:30px; border-radius:50%; + border:1.5px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06); + display:flex; align-items:center; justify-content:center; + cursor:pointer; transition: all 0.15s ease; + } + .qnm-close:hover { border-color:rgba(251,191,36,0.5); background:rgba(251,191,36,0.1); } + + /* ══ 3D ISLAND STAGE ══ */ + .qnm-stage { + position: relative; flex-shrink: 0; + height: 200px; overflow: hidden; + background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%); + } + + /* Sea waves */ + .qnm-sea { + position:absolute; bottom:0; left:0; right:0; height:52px; + background: var(--sea-col); overflow:hidden; + } + .qnm-wave { + position:absolute; bottom:0; left:-100%; width:300%; + height:30px; border-radius:50% 50% 0 0; + background: rgba(255,255,255,0.07); + animation: qnmWave var(--wdur,5s) ease-in-out infinite; + } + .qnm-wave:nth-child(2){ animation-delay:-2s; opacity:0.5; } + .qnm-wave:nth-child(3){ animation-delay:-4s; opacity:0.3; height:20px; } + @keyframes qnmWave { + 0% { transform: translateX(0) scaleY(1); } + 50% { transform: translateX(15%) scaleY(1.08);} + 100%{ transform: translateX(0) scaleY(1); } + } + + /* Floating clouds */ + .qnm-cloud { + position:absolute; border-radius:50px; + background: rgba(255,255,255,0.18); + filter: blur(4px); + animation: qnmDrift var(--cdur,18s) linear infinite; + } + @keyframes qnmDrift { + 0% { transform: translateX(-120px); opacity:0; } + 10% { opacity:1; } + 90% { opacity:1; } + 100%{ transform: translateX(calc(100vw + 120px)); opacity:0; } + } + + /* ── The 3D island container ── */ + .qnm-island-3d-wrap { + position: absolute; + left: 50%; bottom: 40px; + transform: translateX(-50%); + perspective: 420px; + width: 220px; height: 140px; + } + .qnm-island-3d { + width: 100%; height: 100%; + transform-style: preserve-3d; + animation: qnmIslandSpin 18s linear infinite; + position: relative; + } + @keyframes qnmIslandSpin { + 0% { transform: rotateX(22deg) rotateY(0deg); } + 100% { transform: rotateX(22deg) rotateY(360deg); } + } + + /* Island layers — stacked in 3D */ + .qnm-il { /* island layer base class */ + position: absolute; left: 50%; bottom: 0; + transform-origin: bottom center; + border-radius: 50%; + transform-style: preserve-3d; + } + + /* Water base disc */ + .qnm-il-water { + width: 200px; height: 44px; margin-left: -100px; + background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col)); + border-radius: 50%; + transform: translateZ(-4px); + box-shadow: 0 0 40px var(--sea-col); + animation: qnmWaterShimmer 3s ease-in-out infinite; + } + @keyframes qnmWaterShimmer { + 0%,100%{ opacity:1; } + 50% { opacity:0.82; } + } + + /* Ripple rings on water */ + .qnm-ripple { + position:absolute; left:50%; top:50%; + border-radius:50%; border:1.5px solid rgba(255,255,255,0.25); + animation: qnmRipple 2.8s ease-out infinite; + } + .qnm-ripple:nth-child(2){ animation-delay:-1.4s; } + @keyframes qnmRipple { + 0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; } + 100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; } + } + + /* Island ground */ + .qnm-il-ground { + width: 160px; height: 36px; margin-left: -80px; + background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo)); + border-radius: 50%; + transform: translateZ(14px); + box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25); + } + + /* Island side face — gives the 3D depth illusion */ + .qnm-il-side { + width: 158px; height: 22px; margin-left: -79px; + bottom: -12px; + background: linear-gradient(180deg, var(--terr-lo), rgba(0,0,0,0.6)); + clip-path: ellipse(79px 100% at 50% 0%); + transform: translateZ(8px) rotateX(-8deg); + } + + /* Peak */ + .qnm-il-peak { + width: 80px; height: 60px; margin-left: -40px; + bottom: 26px; + background: radial-gradient(ellipse at 42% 25%, var(--peak-hi), var(--peak-mid) 60%, var(--peak-lo)); + clip-path: var(--peak-shape, polygon(50% 0%, 80% 55%, 100% 100%, 0% 100%, 20% 55%)); + transform: translateZ(26px); + filter: drop-shadow(0 6px 12px rgba(0,0,0,0.5)); + animation: qnmPeakBob 4s ease-in-out infinite; + } + @keyframes qnmPeakBob { + 0%,100%{ transform: translateZ(26px) translateY(0); } + 50% { transform: translateZ(26px) translateY(-4px); } + } + + /* Floating decoration layer (trees, cactus, cloud orb, etc.) */ + .qnm-il-deco { + position: absolute; bottom: 56px; left: 50%; + transform: translateZ(42px); + animation: qnmDecoFloat 3s ease-in-out infinite; + } + @keyframes qnmDecoFloat { + 0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); } + 50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); } + } + .qnm-deco-emoji { font-size:1.4rem; filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5)); } + + /* Flag pole on active */ + .qnm-il-flag { + position:absolute; bottom:56px; left:50%; + transform: translateZ(50px) translateX(12px); + } + .qnm-flag-pole { + width:2px; height:26px; background:#7c4a1e; + border-radius:2px; + } + .qnm-flag-cloth { + position:absolute; top:2px; left:2px; + width:16px; height:11px; + background:#ef4444; clip-path:polygon(0%0%,100%25%,0%100%); + animation: qnmFlagWave 1.2s ease-in-out infinite; + transform-origin:left center; + } + @keyframes qnmFlagWave { + 0%,100%{ transform:skewY(0deg); } + 50% { transform:skewY(-10deg); } + } + + /* Stars / sparkles above completed island */ + .qnm-star { + position:absolute; font-size:1rem; + animation: qnmStarPop var(--sdur,2s) ease-in-out infinite; + animation-delay: var(--sdel,0s); + } + @keyframes qnmStarPop { + 0%,100%{ transform:scale(1) translateY(0); opacity:0.8; } + 50% { transform:scale(1.4) translateY(-8px); opacity:1; } + } + + /* ══ CONTENT BELOW THE STAGE ══ */ + .qnm-body { + flex:1; overflow-y:auto; scrollbar-width:none; + display:flex; flex-direction:column; gap:0.85rem; + padding:1.1rem 1.25rem 0.5rem; + } + .qnm-body::-webkit-scrollbar { display:none; } + + /* Title block */ + .qnm-title-block { position:relative; } + .qnm-arc-tag { + display:inline-flex; align-items:center; gap:0.3rem; + font-size:0.58rem; font-weight:800; letter-spacing:0.14em; + text-transform:uppercase; color:var(--ac); + background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.08); + border-radius:100px; padding:0.18rem 0.6rem; margin-bottom:0.45rem; + } + .qnm-quest-title { + font-family:'Cinzel',serif; + font-size:1.22rem; font-weight:700; color:white; + letter-spacing:0.02em; line-height:1.2; margin-bottom:0.18rem; + } + .qnm-island-name { + font-family:'Nunito Sans',sans-serif; + font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38); + } + + /* Flavour quote */ + .qnm-flavour { + background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07); + border-left:3px solid var(--ac); + border-radius:0 14px 14px 0; + padding:0.8rem 1rem; + } + .qnm-flavour-text { + font-family:'Sorts Mill Goudy',serif; + font-size:0.82rem; color:rgba(255,255,255,0.55); + font-style:italic; line-height:1.6; + } + + /* Objective card */ + .qnm-obj-card { + background:rgba(255,255,255,0.04); + border:1px solid rgba(255,255,255,0.08); + border-radius:18px; padding:0.9rem 1rem; + } + .qnm-obj-header { + display:flex; align-items:center; justify-content:space-between; margin-bottom:0.65rem; + } + .qnm-obj-label { + font-size:0.58rem; font-weight:800; letter-spacing:0.14em; + text-transform:uppercase; color:rgba(255,255,255,0.3); + } + .qnm-obj-pct { + font-family:'Nunito',sans-serif; + font-size:0.78rem; font-weight:900; color:var(--ac); + } + .qnm-obj-row { + display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem; + } + .qnm-obj-icon { + width:38px; height:38px; border-radius:12px; flex-shrink:0; + background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08); + display:flex; align-items:center; justify-content:center; font-size:1.1rem; + } + .qnm-obj-text { + font-family:'Nunito',sans-serif; + font-size:0.88rem; font-weight:900; color:white; + } + .qnm-obj-sub { + font-family:'Nunito Sans',sans-serif; + font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem; + } + + /* Progress bar */ + .qnm-bar-track { + height:9px; background:rgba(255,255,255,0.07); + border-radius:100px; overflow:hidden; margin-bottom:0.3rem; + } + .qnm-bar-fill { + height:100%; border-radius:100px; + background:linear-gradient(90deg, var(--ac), color-mix(in srgb, var(--ac) 65%, white)); + box-shadow:0 0 10px color-mix(in srgb, var(--ac) 55%, transparent); + transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1); + } + .qnm-bar-nums { + display:flex; justify-content:space-between; + font-family:'Nunito',sans-serif; + font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28); + } + .qnm-bar-nums span:first-child { color:var(--ac); } + + /* ── HOW TO COMPLETE section ── */ + .qnm-howto-label { + font-size:0.58rem; font-weight:800; letter-spacing:0.14em; + text-transform:uppercase; color:rgba(255,255,255,0.3); + margin-bottom:0.55rem; margin-top:0.3rem; + } + .qnm-howto-badges { + display:flex; flex-wrap:wrap; gap:0.4rem; + } + .qnm-howto-badge { + display:flex; align-items:center; gap:0.3rem; + padding:0.38rem 0.75rem; + background:rgba(255,255,255,0.06); + border:1px solid rgba(255,255,255,0.1); + border-radius:100px; + font-family:'Nunito',sans-serif; + font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7); + transition: all 0.15s ease; + animation: qnmBadgeIn 0.35s cubic-bezier(0.34,1.56,0.64,1) both; + animation-delay: var(--bdel, 0s); + } + @keyframes qnmBadgeIn { + from{ opacity:0; transform:scale(0.8) translateY(6px); } + to { opacity:1; transform:scale(1) translateY(0); } + } + .qnm-howto-badge:hover { + background:rgba(255,255,255,0.1); + border-color:rgba(255,255,255,0.2); + color:white; + transform:translateY(-1px); + } + /* Highlight badge = accent coloured */ + .qnm-howto-badge.hi { + background:color-mix(in srgb, var(--ac) 18%, transparent); + border-color:color-mix(in srgb, var(--ac) 45%, transparent); + color:var(--ac); + } + + /* Locked banner */ + .qnm-locked-banner { + display:flex; align-items:center; gap:0.7rem; + background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07); + border-radius:16px; padding:0.9rem 1rem; + } + .qnm-locked-icon { + width:38px; height:38px; border-radius:12px; flex-shrink:0; + background:rgba(255,255,255,0.05); display:flex; align-items:center; justify-content:center; + } + .qnm-locked-text { + font-family:'Nunito',sans-serif; + font-size:0.82rem; font-weight:800; color:rgba(255,255,255,0.4); + } + .qnm-locked-sub { + font-family:'Nunito Sans',sans-serif; + font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem; + } + + /* Reward card */ + .qnm-reward-card { + background:rgba(251,191,36,0.07); + border:1px solid rgba(251,191,36,0.22); + border-radius:18px; padding:0.9rem 1rem; + } + .qnm-reward-label { + font-size:0.58rem; font-weight:800; letter-spacing:0.14em; + text-transform:uppercase; color:rgba(251,191,36,0.6); margin-bottom:0.6rem; + } + .qnm-reward-row { display:flex; flex-wrap:wrap; gap:0.4rem; } + .qnm-reward-pill { + display:flex; align-items:center; gap:0.28rem; + padding:0.35rem 0.8rem; + background:rgba(251,191,36,0.1); border:1.5px solid rgba(251,191,36,0.3); + border-radius:100px; + font-family:'Nunito',sans-serif; + font-size:0.75rem; font-weight:900; color:#fbbf24; + box-shadow:0 2px 8px rgba(251,191,36,0.1); + } + + /* ══ FOOTER CTA ══ */ + .qnm-footer { + padding:1rem 1.25rem calc(1rem + env(safe-area-inset-bottom)); + flex-shrink:0; border-top:1px solid rgba(255,255,255,0.07); + background:#06101f; + } + .qnm-claim-btn { + width:100%; padding:0.95rem; + background:linear-gradient(135deg,#fbbf24,#f59e0b); + border:none; border-radius:16px; cursor:pointer; + font-family:'Cinzel',serif; font-size:1rem; font-weight:700; color:#1a0800; + letter-spacing:0.04em; + box-shadow:0 5px 0 #d97706, 0 8px 24px rgba(251,191,36,0.35); + transition:all 0.12s ease; + } + .qnm-claim-btn:hover { transform:translateY(-2px); box-shadow:0 7px 0 #d97706; } + .qnm-claim-btn:active { transform:translateY(2px); box-shadow:0 3px 0 #d97706; } + .qnm-note { + text-align:center; + font-family:'Nunito Sans',sans-serif; + font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.28); + padding:0.4rem 0; + } +`; + +// ─── Per-arc terrain themes ─────────────────────────────────────────────────── +interface Terrain { + skyTop: string; + skyBot: string; + seaCol: string; + seaHi: string; + terrHi: string; + terrMid: string; + terrLo: string; + peakHi: string; + peakMid: string; + peakLo: string; + decos: string[]; +} + +const TERRAIN: Record = { + east_blue: { + skyTop: "#0a1628", + skyBot: "#0d2240", + seaCol: "#0a3d5c", + seaHi: "#1a6a8a", + terrHi: "#5eead4", + terrMid: "#0d9488", + terrLo: "#0f5c55", + peakHi: "#a7f3d0", + peakMid: "#34d399", + peakLo: "#065f46", + decos: ["🌴", "🌿"], + }, + alabasta: { + skyTop: "#1c0a00", + skyBot: "#3d1a00", + seaCol: "#7c3a00", + seaHi: "#c26010", + terrHi: "#fde68a", + terrMid: "#d97706", + terrLo: "#78350f", + peakHi: "#fef3c7", + peakMid: "#fbbf24", + peakLo: "#92400e", + decos: ["🌵", "🏺"], + }, + skypiea: { + skyTop: "#1a0033", + skyBot: "#2e0050", + seaCol: "#4c1d95", + seaHi: "#7c3aed", + terrHi: "#e9d5ff", + terrMid: "#a855f7", + terrLo: "#581c87", + peakHi: "#f5d0fe", + peakMid: "#d946ef", + peakLo: "#701a75", + decos: ["☁️", "✨"], + }, +}; +const DEFAULT_TERRAIN = TERRAIN.east_blue; + +// ─── Per-requirement how-to badges ─────────────────────────────────────────── +interface Badge { + emoji: string; + label: string; + highlight?: boolean; +} + +const HOW_TO: Record = { + questions: { + title: "How to complete this", + badges: [ + { emoji: "📝", label: "Take a Practice Sheet", highlight: true }, + { emoji: "⚡", label: "Try Drills Mode" }, + { emoji: "🎯", label: "Aim for 10+ per session" }, + { emoji: "🔄", label: "Retry completed sheets" }, + ], + }, + accuracy: { + title: "How to complete this", + badges: [ + { emoji: "🐢", label: "Slow down, read carefully", highlight: true }, + { emoji: "📖", label: "Review wrong answers" }, + { emoji: "🎯", label: "Do targeted practice", highlight: true }, + { emoji: "💡", label: "Use process of elimination" }, + ], + }, + streak: { + title: "How to complete this", + badges: [ + { emoji: "📅", label: "Practice every day", highlight: true }, + { emoji: "⏰", label: "Set a daily reminder" }, + { emoji: "🔥", label: "Even 5 mins counts" }, + { emoji: "🛡️", label: "Use a Streak Shield" }, + ], + }, + sessions: { + title: "How to complete this", + badges: [ + { emoji: "📝", label: "Start a Practice Sheet", highlight: true }, + { emoji: "⚡", label: "Complete Drills" }, + { emoji: "🏃", label: "Short sessions count too" }, + { emoji: "📚", label: "Try different modules" }, + ], + }, + topics: { + title: "How to complete this", + badges: [ + { emoji: "🗺️", label: "Explore new modules", highlight: true }, + { emoji: "📊", label: "Check your weak topics" }, + { emoji: "🔍", label: "Use Search to find topics" }, + { emoji: "⚡", label: "Drill each topic once" }, + ], + }, + xp: { + title: "How to complete this", + badges: [ + { emoji: "🎯", label: "High accuracy = bonus XP", highlight: true }, + { emoji: "🔥", label: "Maintain your streak" }, + { emoji: "📝", label: "Complete full sheets", highlight: true }, + { emoji: "⚡", label: "Use XP Boosts" }, + ], + }, + leaderboard: { + title: "How to complete this", + badges: [ + { emoji: "📈", label: "Aim for 80%+ accuracy", highlight: true }, + { emoji: "🔥", label: "Keep your streak alive" }, + { emoji: "📝", label: "Do sessions daily" }, + { emoji: "🏆", label: "Check the leaderboard" }, + ], + }, +}; + +// ─── Island shape configs (mirrors the 6 clip-path shapes in QuestMap) ──────── +// groundClip = clip-path for the flat top disc of the island +// peakClip = clip-path for the hill/feature rising above it +// groundW/H = pixel size of the ground layer +// peakW/H = pixel size of the peak layer +// sideClip = clip-path for the side-face depth layer +interface ShapeConfig { + groundClip: string; + peakClip: string; + sideClip: string; + groundW: number; + groundH: number; + peakW: number; + peakH: number; + peakBottom: number; // translateZ bottom offset in px +} + +// These correspond 1-to-1 with SHAPES[0..5] in QuestMap.tsx +const ISLAND_SHAPES: ShapeConfig[] = [ + // 0: fat round atoll + { + groundClip: "ellipse(50% 50% at 50% 50%)", + peakClip: "ellipse(50% 50% at 50% 55%)", + sideClip: "ellipse(50% 100% at 50% 0%)", + groundW: 160, + groundH: 38, + peakW: 88, + peakH: 38, + peakBottom: 26, + }, + // 1: tall mountain — narrow diamond ground, sharp triangular peak + { + groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)", + peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)", + sideClip: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)", + groundW: 148, + groundH: 36, + peakW: 72, + peakH: 72, + peakBottom: 24, + }, + // 2: wide flat shoal — extra-wide squashed ellipse, low dome + { + groundClip: "ellipse(50% 40% at 50% 58%)", + peakClip: "ellipse(50% 38% at 50% 60%)", + sideClip: "ellipse(50% 100% at 50% 0%)", + groundW: 178, + groundH: 30, + peakW: 114, + peakH: 28, + peakBottom: 22, + }, + // 3: jagged rocky reef — star-burst polygon + { + groundClip: + "polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)", + peakClip: + "polygon(50% 0%, 63% 32%, 98% 32%, 71% 54%, 80% 90%, 50% 70%, 20% 90%, 29% 54%, 2% 32%, 37% 32%)", + sideClip: "ellipse(50% 100% at 50% 0%)", + groundW: 152, + groundH: 38, + peakW: 80, + peakH: 66, + peakBottom: 24, + }, + // 4: crescent — lopsided asymmetric bean + { + groundClip: + "path('M 80 10 C 120 5, 150 30, 145 55 C 140 78, 110 88, 80 85 C 55 82, 38 70, 42 55 C 46 42, 62 40, 68 50 C 74 60, 65 70, 55 68 C 38 62, 30 42, 42 28 C 55 12, 70 12, 80 10 Z')", + peakClip: "ellipse(38% 55% at 38% 50%)", + sideClip: "ellipse(50% 100% at 50% 0%)", + groundW: 160, + groundH: 36, + peakW: 80, + peakH: 58, + peakBottom: 22, + }, + // 5: teardrop/pear — narrow top, wide rounded base + { + groundClip: + "path('M 50 4 C 72 4, 95 28, 95 55 C 95 78, 76 94, 50 94 C 24 94, 5 78, 5 55 C 5 28, 28 4, 50 4 Z')", + peakClip: + "polygon(50% 0%, 73% 27%, 88% 62%, 68% 98%, 32% 98%, 12% 62%, 27% 27%)", + sideClip: "ellipse(50% 100% at 50% 0%)", + groundW: 144, + groundH: 38, + peakW: 76, + peakH: 66, + peakBottom: 24, + }, +]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── +const reqIcon = (type: string): string => + ({ + questions: "❓", + accuracy: "🎯", + streak: "🔥", + sessions: "📚", + topics: "🗺️", + xp: "⚡", + leaderboard: "🏆", + })[type] ?? "⭐"; + +// ─── 3D Island Stage ────────────────────────────────────────────────────────── +const IslandStage = ({ + arcId, + status, + nodeIndex, +}: { + arcId: string; + status: QuestNode["status"]; + nodeIndex: number; +}) => { + const t = TERRAIN[arcId] ?? DEFAULT_TERRAIN; + const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length]; + + const isCompleted = status === "completed"; + const isClaimable = status === "claimable"; + const isActive = status === "active"; + const isLocked = status === "locked"; + + const vars = { + "--sky-top": t.skyTop, + "--sky-bot": t.skyBot, + "--sea-col": t.seaCol, + "--sea-hi": t.seaHi, + "--terr-hi": t.terrHi, + "--terr-mid": t.terrMid, + "--terr-lo": t.terrLo, + "--peak-hi": t.peakHi, + "--peak-mid": t.peakMid, + "--peak-lo": t.peakLo, + } as React.CSSProperties; + + return ( +
+ {/* Clouds */} + {[ + { w: 70, h: 22, top: 14, delay: 0, dur: 16 }, + { w: 50, h: 16, top: 28, delay: -7, dur: 22 }, + { w: 40, h: 14, top: 10, delay: -3, dur: 28 }, + ].map((c, i) => ( +
+ ))} + + {/* Sea + waves */} +
+
+
+
+
+ + {/* Ripple rings on water surface */} +
+
+
+
+ + {/* 3D island */} +
+
+ {/* Water base */} +
+ + {/* Island side face */} +
+ + {/* Island ground — shaped to match QuestMap */} +
+ + {/* Peak / hill — shaped to match QuestMap */} + {!isLocked && ( +
+ )} + + {/* Decorations */} + {!isLocked && + t.decos.map((deco, di) => ( +
+ {deco} +
+ ))} + + {/* Pirate flag on active */} + {isActive && ( +
+
+
+
+ )} + + {/* Chest bouncing on claimable */} + {isClaimable && ( +
+ + 📦 + +
+ )} + + {/* Lock icon on locked */} + {isLocked && ( +
+ 🔒 +
+ )} +
+
+ + {/* Sparkles for completed */} + {isCompleted && ( + <> + {[ + { left: "30%", top: "18%", sdur: "2s", sdel: "0s" }, + { left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" }, + { left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" }, + { left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" }, + ].map((s, i) => ( + + ✨ + + ))} + + )} + + {/* Lock overlay tint */} + {isLocked && ( +
+ 🔒 +
+ )} +
+ ); +}; + +// ─── Main component ─────────────────────────────────────────────────────────── +interface Props { + node: QuestNode; + arcAccent: string; + arcDark: string; + arcId?: string; + nodeIndex?: number; + onClose: () => void; + onClaim: () => void; +} + +export const QuestNodeModal = ({ + node, + arcAccent, + arcDark, + arcId = "east_blue", + nodeIndex = 0, + onClose, + onClaim, +}: Props) => { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + const progress = Math.min( + 100, + Math.round((node.progress / node.requirement.target) * 100), + ); + const isClaimable = node.status === "claimable"; + const isLocked = node.status === "locked"; + const isCompleted = node.status === "completed"; + const isActive = node.status === "active"; + const howTo = HOW_TO[node.requirement.type]; + + return ( +
+ +
e.stopPropagation()}> + {/* Handle + close */} +
+
+
+ + + {/* 3D island stage */} + + + {/* Scrollable content */} +
+ {/* Title */} +
+
+ {reqIcon(node.requirement.type)} Quest +
+

{node.title}

+

📍 {node.islandName}

+
+ + {/* Flavour */} +
+

{node.flavourText}

+
+ + {/* Objective */} +
+
+ ⚓ Objective + {!isLocked && ( + + {isCompleted ? "✅ Done" : `${progress}%`} + + )} +
+
+
+ {reqIcon(node.requirement.type)} +
+
+

+ {node.requirement.target} {node.requirement.label} +

+

+ {isCompleted + ? "✅ Completed — treasure claimed!" + : isLocked + ? "🔒 Complete previous quests first" + : `${node.progress} / ${node.requirement.target} done`} +

+
+
+ + {/* Progress bar */} + {!isLocked && ( + <> +
+
+
+
+ {node.progress} + {node.requirement.target} +
+ + )} + + {/* How-to badges — show when active or claimable */} + {(isActive || isClaimable) && howTo && ( + <> +

+ 🧭 {howTo.title} +

+
+ {howTo.badges.map((b, bi) => ( +
+ {b.emoji} + {b.label} +
+ ))} +
+ + )} +
+ + {/* Locked banner */} + {isLocked && ( +
+
+ +
+
+

Quest Locked

+

+ Complete the previous island to sail here +

+
+
+ )} + + {/* Reward */} +
+

📦 Treasure Chest

+
+
⚡ +{node.reward.xp} XP
+ {node.reward.title && ( +
🏴‍☠️ {node.reward.title}
+ )} + {node.reward.itemLabel && ( +
+ 🎁 {node.reward.itemLabel} +
+ )} +
+
+
+ + {/* Footer CTA */} +
+ {isClaimable ? ( + + ) : isCompleted ? ( +

✅ Completed — treasure claimed!

+ ) : isLocked ? ( +

🔒 Locked — keep sailing

+ ) : ( +

+ {progress}% complete · {node.requirement.target - node.progress}{" "} + {node.requirement.label} remaining +

+ )} +
+
+
+ ); +}; diff --git a/src/components/QuestProgressCard.tsx b/src/components/QuestProgressCard.tsx new file mode 100644 index 0000000..3cbf784 --- /dev/null +++ b/src/components/QuestProgressCard.tsx @@ -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 ( + <> + + +
+ {/* Atmosphere layers */} +
+
+ + {/* ── Rank hero (always visible, tap to expand) ── */} +
setOpen((o) => !o)}> +
+
+
{rank.emoji}
+
+

Crew Rank

+

{rank.label}

+
+
+
+ {summary.claimableNodes > 0 && ( +
+ 📦 {summary.claimableNodes} +
+ )} + +
+
+ + {/* Rank progress bar */} +
+
+
+
+ {nextRankLabel} +
+ + {/* Stats strip */} +
+ {[ + { 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 ? ( +
+ ) : ( +
+ {item.val} + {item.lbl} +
+ ), + )} +
+
+ + {/* ── Collapsible quest list ── */} +
+
+
+ {activeQuests.length === 0 ? ( +
+ +

All caught up, Captain!

+

+ No active quests — keep sailing +

+
+ ) : ( + activeQuests.map(({ node, arc }) => { + const pct = Math.min( + 100, + Math.round((node.progress / node.requirement.target) * 100), + ); + const isClaimable = node.status === "claimable"; + return ( +
!isClaimable && handleViewAll()} + > +
+ {isClaimable ? "📦" : node.emoji} +
+
+

+ {arc.emoji} {arc.name} +

+

{node.title}

+ {isClaimable ? ( +

+ ✨ Chest ready to open! +

+ ) : ( + <> +
+
+
+

+ {node.progress} / {node.requirement.target}{" "} + {node.requirement.label} +

+ + )} +
+ {isClaimable ? ( + + ) : ( + + )} +
+ ); + }) + )} +
+ + {/* Footer — navigate to full map */} +
+ View full quest map + +
+
+
+ + {claimingNode && ( + + )} + + ); +}; diff --git a/src/data/questData.ts b/src/data/questData.ts new file mode 100644 index 0000000..5f1d233 --- /dev/null +++ b/src/data/questData.ts @@ -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", + }, + }, + ], + }, +]; diff --git a/src/hooks/useCrewRank.ts b/src/hooks/useCrewRank.ts new file mode 100644 index 0000000..a48d9ee --- /dev/null +++ b/src/hooks/useCrewRank.ts @@ -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"; +} diff --git a/src/pages/ErrorPage.tsx b/src/pages/ErrorPage.tsx new file mode 100644 index 0000000..935634c --- /dev/null +++ b/src/pages/ErrorPage.tsx @@ -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 ( +
+
+

{title}

+

{message}

+ + +
+
+ ); +} diff --git a/src/pages/student/Home.tsx b/src/pages/student/Home.tsx index 4f379f8..fd2a2da 100644 --- a/src/pages/student/Home.tsx +++ b/src/pages/student/Home.tsx @@ -18,6 +18,9 @@ import { DrawerTrigger, } from "../../components/ui/drawer"; import { useExamConfigStore } from "../../stores/useExamConfigStore"; +import { QuestProgressCard } from "../../components/QuestProgressCard"; + +// somewhere in the Home JSX, above the sheets tabs: // ─── Shared blob/dot background (same as break/results screens) ──────────────── const DOTS = [ @@ -496,7 +499,7 @@ export const Home = () => { onFocus={() => setIsSearchOpen(true)} />
- + navigate("/student/quests")} /> {/* ── In progress ── */}

📌 Pick up where you left off

diff --git a/src/pages/student/QuestMap.tsx b/src/pages/student/QuestMap.tsx new file mode 100644 index 0000000..1137d05 --- /dev/null +++ b/src/pages/student/QuestMap.tsx @@ -0,0 +1,996 @@ +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"; + +// ─── 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 = { + 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 + ``, + // 1: tall mountain peak + ``, + // 2: wide flat shoal + ``, + // 3: jagged rocky reef + ``, + // 4: crescent (right side bites in) + ``, + // 5: teardrop/pear + ``, +]; + +// ─── 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 = + { + 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 = { + east_blue: ["🌴", "🌿", "🌴"], + alabasta: ["🌵", "🏺", "🌵"], + skypiea: ["☁️", "✨", "☁️"], +}; +const REQ_ICON: Record = { + 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 ( + !isLocked && onTap(node)} + > + + + + + + + + + + + + + + + + + + + + + + + + {/* Water shimmer halo */} + + + + + + {/* Land shadow blob */} + + + + + {/* Active / claimable glow ring */} + {(isActive || isClaimable) && ( + + ", + ` fill="none" stroke="${isClaimable ? "#fbbf24" : accent}" stroke-width="1.8" stroke-dasharray="6 4" opacity="0.6">`, + ) + .replace("<", "<"), + }} + /> + + )} + + {/* Land shape */} + + ", ` fill="url(#${gradId})">`), + }} + /> + + + {/* Decorations */} + {!isLocked && ( + <> + + {decos[0]} + + + {decos[1]} + + + )} + + {/* Pirate flag on active */} + {isActive && ( + + + + + )} + + {/* Bouncing chest on claimable */} + {isClaimable && ( + + 📦 + + + )} + + {/* Lock icon */} + {isLocked && ( + + 🔒 + + )} + + {/* Quest emoji */} + {!isLocked && ( + + {node.emoji} + + )} + + {/* Completed check */} + {isCompleted && ( + + + + ✓ + + + )} + + {/* Island name label */} + + {node.islandName?.toUpperCase()} + + + {/* Info card via foreignObject */} + e.stopPropagation()} + > +
!isLocked && onTap(node)} + > +
+

{node.title}

+
+ + +{node.reward.xp} +
+
+ {(isActive || isClaimable) && ( + <> +
+
+
+

+ {REQ_ICON[node.requirement.type]}  + {node.progress}/{node.requirement.target}{" "} + {node.requirement.label} +

+ + )} + {isLocked && ( +

+ 🔒 {node.requirement.target} {node.requirement.label} to unlock +

+ )} + {isCompleted && ( +

+ ✅ Conquered! +

+ )} + {isClaimable && ( + + )} +
+ + + ); +}; + +// ─── 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 ( + + + + {[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 ( + + ); + })} + {showShip && ( + + ⛵ + + + + )} + + ); +}; + +// ─── 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(null); + const [claimingNode, setClaimingNode] = useState(null); + const scrollRef = useRef(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 ( +
+ + + {/* Header */} +
+

🏴‍☠️ Treasure Quests

+

Chart your course across the Grand Line

+
+ {[ + { + 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) => ( +
+ {s.e} + {s.v} + {s.l} +
+ ))} +
+
+ {arcs.map((a) => ( + + ))} +
+
+ + {/* Sea */} +
+
+
+ {FOAM.map((b) => ( +
+ ))} + + {/* Arc banner */} +
+
{arc.emoji}
+

{arc.name}

+

{arc.subtitle}

+
+
+
+
+ + {done}/{arc.nodes.length} islands + +
+
+ + {/* ── Single SVG canvas for the whole map ── */} + + {/* 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 ( + + ); + })} + + {/* Islands drawn on top */} + {nodes.map((node, i) => ( + + ))} + + {/* Arc complete seal */} + {done === nodes.length && ( + + + + + ARC + + + COMPLETE + + + ⚓ + + + )} + +
+
+ +
+ scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }) + } + > + 🏴‍☠️ +
+ + {selectedNode && ( + n.id === selectedNode.id)} + onClose={() => setSelectedNode(null)} + onClaim={() => { + setSelectedNode(null); + handleClaim(selectedNode); + }} + /> + )} + {claimingNode && ( + + )} +
+ ); +}; diff --git a/src/pages/student/StudentLayout.tsx b/src/pages/student/StudentLayout.tsx index aa3bae0..c872462 100644 --- a/src/pages/student/StudentLayout.tsx +++ b/src/pages/student/StudentLayout.tsx @@ -1,5 +1,5 @@ -import { Outlet, NavLink } from "react-router-dom"; -import { Home, BookOpen, Award, User, Video } from "lucide-react"; +import { Outlet, NavLink, useLocation } from "react-router-dom"; +import { Home, BookOpen, Award, User, Video, Map } from "lucide-react"; import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar"; import { AppSidebar } from "../../components/AppSidebar"; @@ -18,6 +18,13 @@ const NAV_ITEMS = [ color: "#a855f7", bg: "rgba(168,85,247,0.12)", }, + { + to: "/student/quests", + icon: Map, + label: "Quests", + color: "#587ffc", + bg: "rgba(53,75,150,0.12)", + }, { to: "/student/lessons", icon: Video, @@ -41,19 +48,26 @@ const NAV_ITEMS = [ }, ]; -const STYLES = ` - @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap'); +// ── Quest dock overrides: dark navy pirate theme ────────────────────────────── +// Active color on quests page gets the gold treatment instead of the tab color. +const QUEST_NAV_ITEMS = NAV_ITEMS.map((item) => + item.to === "/student/quests" + ? { ...item, color: "#fbbf24", bg: "rgba(251,191,36,0.15)" } + : { ...item, color: "#94a3b8", bg: "rgba(255,255,255,0.08)" }, +); - /* ── 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 { position: fixed; bottom: calc(1.25rem + env(safe-area-inset-bottom)); left: 50%; transform: translateX(-50%); z-index: 20; - - /* Frosted pill */ - background: rgba(255, 251, 244, 0.72); + background: rgba(255,251,244,0.72); backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: blur(24px) saturate(180%); border: 1.5px solid rgba(255,255,255,0.7); @@ -62,11 +76,42 @@ const STYLES = ` 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.8); - padding: 0.45rem 0.5rem; display: flex; align-items: center; gap: 0.15rem; + transition: + background 0.4s ease, + border-color 0.4s ease, + box-shadow 0.4s ease; + } + + /* ══ QUEST dock (dark navy pirate) ══ */ + .sl-dock-wrap.quest-mode { + background: linear-gradient( + 90deg, + transparent 0%, + rgba(251,191,36,0.05) 30%, + rgba(251,191,36,0.1) 50%, + rgba(251,191,36,0.15) 70%, + transparent 100% + ); + background-size: 200% 100%; + animation: slGoldSweep 3s linear infinite; + backdrop-filter: blur(28px) saturate(160%); + -webkit-backdrop-filter: blur(28px) saturate(160%); + border: 1.5px solid rgba(251,191,36,0.28); + box-shadow: + 0 8px 32px rgba(0,0,0,0.55), + 0 2px 8px rgba(0,0,0,0.35), + 0 0 0 1px rgba(251,191,36,0.08), + inset 0 1px 0 rgba(255,255,255,0.06); + } + + + @keyframes slGoldSweep { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } } /* ── Each nav item ── */ @@ -83,10 +128,11 @@ const STYLES = ` -webkit-tap-highlight-color: transparent; transition: padding 0.35s cubic-bezier(0.34,1.56,0.64,1), - gap 0.35s cubic-bezier(0.34,1.56,0.64,1), + gap 0.35s cubic-bezier(0.34,1.56,0.64,1), background 0.25s ease; white-space: nowrap; overflow: hidden; + position: relative; } .sl-dock-item:active { transform: scale(0.91); } .sl-dock-item.active { @@ -102,11 +148,14 @@ const STYLES = ` background: transparent; transition: background 0.25s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1); } - .sl-dock-item.active .sl-dock-icon { - transform: scale(1.1); + .sl-dock-item.active .sl-dock-icon { transform: scale(1.1); } + + /* In quest mode, active quest icon gets a gold glow */ + .quest-mode .sl-dock-item.active .sl-dock-icon { + box-shadow: 0 0 12px rgba(251,191,36,0.35); } - /* ── Label (only visible when active) ── */ + /* ── Label ── */ .sl-dock-label { font-family: 'Nunito', sans-serif; font-size: 0.8rem; @@ -124,9 +173,32 @@ const STYLES = ` max-width: 80px; opacity: 1; } + + /* 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; + } `; 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 ( @@ -136,15 +208,16 @@ export function StudentLayout() {
- {/* Extra bottom padding so content clears the floating dock */}
- {/* ── Floating island dock (mobile only) ── */} -
diff --git a/src/stores/useQuestStore.ts b/src/stores/useQuestStore.ts new file mode 100644 index 0000000..0f07289 --- /dev/null +++ b/src/stores/useQuestStore.ts @@ -0,0 +1,165 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import type { QuestArc, QuestNode, NodeStatus } from "../types/quest"; +import { CREW_RANKS } from "../types/quest"; +import { QUEST_ARCS } from "../data/questData"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface CrewRank { + id: string; + label: string; + emoji: string; + xpRequired: number; + progressToNext: number; // 0–1 toward next rank + next: { label: string; xpRequired: number } | null; +} + +export interface QuestSummary { + totalNodes: number; + completedNodes: number; + activeNodes: number; + claimableNodes: number; + lockedNodes: number; + totalXP: number; + earnedXP: number; + arcsCompleted: number; + totalArcs: number; + earnedTitles: string[]; + crewRank: CrewRank; +} + +// ─── Store — ONLY raw state + actions, never derived values ─────────────────── +// Storing functions that return new objects/arrays in Zustand causes infinite +// re-render loops because Zustand uses Object.is to detect changes. +// All derived values live below as plain helper functions instead. + +interface QuestStore { + arcs: QuestArc[]; + activeArcId: string; + setActiveArc: (arcId: string) => void; + claimNode: (arcId: string, nodeId: string) => void; + syncFromAPI: (arcs: QuestArc[]) => void; +} + +export const useQuestStore = create()( + 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]; +} diff --git a/src/types/quest.ts b/src/types/quest.ts new file mode 100644 index 0000000..e18b166 --- /dev/null +++ b/src/types/quest.ts @@ -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;