feat(treasure): add treasure quest, quest modal, island node, quest widget
This commit is contained in:
@ -20,6 +20,8 @@ import { TargetedPractice } from "./pages/student/targeted-practice/page";
|
|||||||
import { Drills } from "./pages/student/drills/page";
|
import { Drills } from "./pages/student/drills/page";
|
||||||
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
import { HardTestModules } from "./pages/student/hard-test-modules/page";
|
||||||
import { Analytics } from "./pages/student/Analytics";
|
import { Analytics } from "./pages/student/Analytics";
|
||||||
|
import { QuestMap } from "./pages/student/QuestMap";
|
||||||
|
import ErrorPage from "./pages/ErrorPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -58,6 +60,10 @@ function App() {
|
|||||||
path: "analytics",
|
path: "analytics",
|
||||||
element: <Analytics />,
|
element: <Analytics />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "quests",
|
||||||
|
element: <QuestMap />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "practice/:sheetId",
|
path: "practice/:sheetId",
|
||||||
element: <Pretest />,
|
element: <Pretest />,
|
||||||
|
|||||||
718
src/components/ChestOpenModal.tsx
Normal file
718
src/components/ChestOpenModal.tsx
Normal file
@ -0,0 +1,718 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import type { QuestNode } from "../types/quest";
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const S = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@700;900&family=Nunito:wght@800;900&display=swap');
|
||||||
|
|
||||||
|
/* ══ FULL SCREEN OVERLAY ══ */
|
||||||
|
.com-overlay {
|
||||||
|
position:fixed; inset:0; z-index:80;
|
||||||
|
display:flex; flex-direction:column;
|
||||||
|
align-items:center; justify-content:center;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sky/sea background that animates in ── */
|
||||||
|
.com-bg {
|
||||||
|
position:absolute; inset:0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 80%, rgba(0,60,120,0.9) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 50% 20%, rgba(80,0,160,0.7) 0%, transparent 60%),
|
||||||
|
linear-gradient(180deg, #050010 0%, #0a0520 40%, #020818 100%);
|
||||||
|
animation: comBgIn 0.5s ease both;
|
||||||
|
}
|
||||||
|
@keyframes comBgIn {
|
||||||
|
from{ opacity:0; } to{ opacity:1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stars in background ── */
|
||||||
|
.com-star {
|
||||||
|
position:absolute; border-radius:50%;
|
||||||
|
background:white; pointer-events:none;
|
||||||
|
animation:comStarTwinkle var(--sdur) ease-in-out infinite;
|
||||||
|
animation-delay:var(--sdelay);
|
||||||
|
}
|
||||||
|
@keyframes comStarTwinkle {
|
||||||
|
0%,100%{ opacity:0.3; transform:scale(1); }
|
||||||
|
50% { opacity:1; transform:scale(1.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gold radial burst (appears on open) ── */
|
||||||
|
.com-burst {
|
||||||
|
position:absolute; inset:0;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
pointer-events:none; z-index:2;
|
||||||
|
}
|
||||||
|
.com-burst-ring {
|
||||||
|
position:absolute; border-radius:50%;
|
||||||
|
border:3px solid rgba(251,191,36,0.6);
|
||||||
|
animation: comBurstRing var(--brdur) ease-out forwards;
|
||||||
|
animation-delay: var(--brdelay);
|
||||||
|
opacity:0;
|
||||||
|
}
|
||||||
|
@keyframes comBurstRing {
|
||||||
|
0% { opacity:0.9; transform:scale(0.1); }
|
||||||
|
100%{ opacity:0; transform:scale(var(--brs)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ray beams (crepuscular rays) ── */
|
||||||
|
.com-rays {
|
||||||
|
position:absolute; inset:0;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
pointer-events:none; z-index:1;
|
||||||
|
}
|
||||||
|
.com-ray {
|
||||||
|
position:absolute;
|
||||||
|
width:3px;
|
||||||
|
height:55vh;
|
||||||
|
border-radius:100px;
|
||||||
|
background:linear-gradient(180deg,rgba(251,191,36,0.5) 0%,transparent 100%);
|
||||||
|
transform-origin:50% 100%;
|
||||||
|
bottom:50%;
|
||||||
|
left:calc(50% - 1.5px);
|
||||||
|
transform:rotate(var(--angle)) scaleY(0);
|
||||||
|
animation:comRayIn 0.6s ease-out forwards;
|
||||||
|
animation-delay:var(--raydelay);
|
||||||
|
}
|
||||||
|
@keyframes comRayIn {
|
||||||
|
0% { transform:rotate(var(--angle)) scaleY(0); opacity:0; }
|
||||||
|
40% { opacity:0.8; }
|
||||||
|
100%{ transform:rotate(var(--angle)) scaleY(1); opacity:0.15; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Particle explosion ── */
|
||||||
|
.com-particle {
|
||||||
|
position:absolute; border-radius:50%;
|
||||||
|
pointer-events:none; z-index:4;
|
||||||
|
animation:comParticleOut var(--pdur) cubic-bezier(0.25,0.8,0.35,1) forwards;
|
||||||
|
animation-delay:var(--pdelay);
|
||||||
|
opacity:0;
|
||||||
|
}
|
||||||
|
@keyframes comParticleOut {
|
||||||
|
0% { opacity:1; transform:translate(0,0) scale(1) rotate(0deg); }
|
||||||
|
80% { opacity:0.7; }
|
||||||
|
100%{ opacity:0; transform:translate(var(--ptx),var(--pty)) scale(0.2) rotate(var(--prot)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Coin emojis bursting ── */
|
||||||
|
.com-coin {
|
||||||
|
position:absolute;
|
||||||
|
font-size:var(--csize);
|
||||||
|
pointer-events:none; z-index:4;
|
||||||
|
animation:comCoinOut var(--cdur) cubic-bezier(0.2,0.9,0.3,1) forwards;
|
||||||
|
animation-delay:var(--cdelay);
|
||||||
|
opacity:0;
|
||||||
|
}
|
||||||
|
@keyframes comCoinOut {
|
||||||
|
0% { opacity:0; transform:translate(0,0) rotate(0deg) scale(0.3); }
|
||||||
|
12% { opacity:1; }
|
||||||
|
100%{ opacity:0; transform:translate(var(--ctx),var(--cty)) rotate(var(--crot)) scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Floating sparkles (stay on screen) ── */
|
||||||
|
.com-sparkle {
|
||||||
|
position:absolute; pointer-events:none; z-index:3;
|
||||||
|
font-size:var(--spsize);
|
||||||
|
animation:comSparkleFloat var(--spdur) ease-in-out infinite;
|
||||||
|
animation-delay:var(--spdelay);
|
||||||
|
opacity:0.7;
|
||||||
|
}
|
||||||
|
@keyframes comSparkleFloat {
|
||||||
|
0%,100%{ transform:translateY(0) rotate(0deg) scale(1); opacity:0.6; }
|
||||||
|
50% { transform:translateY(-18px) rotate(180deg) scale(1.2); opacity:1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── XP number that flies up from chest ── */
|
||||||
|
.com-xp-blast {
|
||||||
|
position:absolute; pointer-events:none; z-index:5;
|
||||||
|
top:50%; left:50%;
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:2.6rem; font-weight:900;
|
||||||
|
color:#fbbf24;
|
||||||
|
text-shadow:0 0 30px rgba(251,191,36,1),0 0 60px rgba(251,191,36,0.7),0 0 100px rgba(251,191,36,0.3);
|
||||||
|
white-space:nowrap;
|
||||||
|
animation:comXPBlast 2s cubic-bezier(0.2,0.8,0.3,1) forwards;
|
||||||
|
}
|
||||||
|
@keyframes comXPBlast {
|
||||||
|
0% { opacity:0; transform:translate(-50%,-40%) scale(0.4); filter:blur(4px); }
|
||||||
|
15% { opacity:1; transform:translate(-50%,-60%) scale(1.3); filter:blur(0); }
|
||||||
|
60% { opacity:1; transform:translate(-50%,-90%) scale(1); }
|
||||||
|
100%{ opacity:0; transform:translate(-50%,-130%) scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main card ── */
|
||||||
|
.com-card {
|
||||||
|
position:relative; z-index:6;
|
||||||
|
width:calc(100% - 2.5rem); max-width:340px;
|
||||||
|
border-radius:28px; overflow:hidden;
|
||||||
|
display:flex; flex-direction:column; align-items:center;
|
||||||
|
padding:0;
|
||||||
|
box-shadow:0 0 80px rgba(251,191,36,0.2), 0 24px 64px rgba(0,0,0,0.7);
|
||||||
|
animation:comCardIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
animation-delay:0.1s;
|
||||||
|
}
|
||||||
|
@keyframes comCardIn {
|
||||||
|
from{ opacity:0; transform:scale(0.8) translateY(24px); }
|
||||||
|
to { opacity:1; transform:scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold shimmer top border */
|
||||||
|
.com-card::before {
|
||||||
|
content:''; position:absolute; top:0; left:0; right:0; height:2px; z-index:1;
|
||||||
|
background:linear-gradient(90deg,transparent 0%,#f59e0b 30%,#fbbf24 50%,#f59e0b 70%,transparent 100%);
|
||||||
|
background-size:200% 100%;
|
||||||
|
animation:comShimmer 2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes comShimmer {
|
||||||
|
0% { background-position:200% 0; }
|
||||||
|
100%{ background-position:-200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card inner bg */
|
||||||
|
.com-card-inner {
|
||||||
|
width:100%; padding:1.75rem 1.6rem 1.6rem;
|
||||||
|
background:linear-gradient(160deg,#12083a 0%,#0c0525 60%,#090320 100%);
|
||||||
|
border:1.5px solid rgba(251,191,36,0.25);
|
||||||
|
border-radius:28px;
|
||||||
|
display:flex; flex-direction:column; align-items:center; gap:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Phase label ── */
|
||||||
|
.com-label {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:0.62rem; font-weight:700; letter-spacing:0.2em;
|
||||||
|
text-transform:uppercase; color:rgba(251,191,36,0.55);
|
||||||
|
margin-bottom:1.2rem; text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chest area ── */
|
||||||
|
.com-chest-area {
|
||||||
|
position:relative; width:140px; height:140px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
margin-bottom:1.25rem; cursor:pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow platform beneath chest */
|
||||||
|
.com-glow-pad {
|
||||||
|
position:absolute; bottom:6px; left:50%;
|
||||||
|
transform:translateX(-50%);
|
||||||
|
width:100px; height:24px; border-radius:50%;
|
||||||
|
background:radial-gradient(ellipse at center,rgba(251,191,36,0.45) 0%,transparent 70%);
|
||||||
|
animation:comGlowPad 1.8s ease-in-out infinite;
|
||||||
|
filter:blur(4px);
|
||||||
|
}
|
||||||
|
@keyframes comGlowPad {
|
||||||
|
0%,100%{ transform:translateX(-50%) scaleX(1); opacity:0.7; }
|
||||||
|
50% { transform:translateX(-50%) scaleX(1.2); opacity:1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orbit ring */
|
||||||
|
.com-orbit {
|
||||||
|
position:absolute; inset:8px; border-radius:50%;
|
||||||
|
border:1.5px dashed rgba(251,191,36,0.2);
|
||||||
|
animation:comOrbit 8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes comOrbit { from{transform:rotate(0deg);} to{transform:rotate(360deg);} }
|
||||||
|
.com-orbit-dot {
|
||||||
|
position:absolute; top:-5px; left:50%; transform:translateX(-50%);
|
||||||
|
width:8px; height:8px; border-radius:50%;
|
||||||
|
background:#fbbf24; box-shadow:0 0 10px #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chest emoji */
|
||||||
|
.com-chest {
|
||||||
|
font-size:5.5rem; position:relative; z-index:2;
|
||||||
|
filter:drop-shadow(0 8px 20px rgba(251,191,36,0.45));
|
||||||
|
transition:filter 0.2s;
|
||||||
|
}
|
||||||
|
.com-chest.idle {
|
||||||
|
animation:comChestIdle 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes comChestIdle {
|
||||||
|
0%,100%{ transform:translateY(0) rotate(-2deg); }
|
||||||
|
50% { transform:translateY(-6px) rotate(2deg); }
|
||||||
|
}
|
||||||
|
.com-chest.shake {
|
||||||
|
animation:comChestShake 0.55s cubic-bezier(0.36,0.07,0.19,0.97) both;
|
||||||
|
}
|
||||||
|
@keyframes comChestShake {
|
||||||
|
0%,100%{ transform:rotate(0deg) scale(1); }
|
||||||
|
10% { transform:rotate(-14deg) scale(1.06); }
|
||||||
|
25% { transform:rotate(14deg) scale(1.1); }
|
||||||
|
40% { transform:rotate(-10deg) scale(1.07); }
|
||||||
|
55% { transform:rotate(10deg) scale(1.12); }
|
||||||
|
70% { transform:rotate(-6deg) scale(1.06); }
|
||||||
|
85% { transform:rotate(6deg) scale(1.04); }
|
||||||
|
}
|
||||||
|
.com-chest.opening {
|
||||||
|
animation:comChestOpen 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes comChestOpen {
|
||||||
|
0% { transform:scale(0.7); filter:brightness(0.4) drop-shadow(0 8px 20px rgba(251,191,36,0.3)); }
|
||||||
|
50% { transform:scale(1.25); filter:brightness(1.8) drop-shadow(0 0 50px rgba(251,191,36,1)); }
|
||||||
|
100%{ transform:scale(1); filter:brightness(1) drop-shadow(0 8px 30px rgba(251,191,36,0.6)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tap prompt ── */
|
||||||
|
.com-tap-title {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:1.2rem; font-weight:900; color:white;
|
||||||
|
text-align:center; margin-bottom:0.3rem;
|
||||||
|
animation:comPulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes comPulse {
|
||||||
|
0%,100%{ opacity:1; transform:scale(1); }
|
||||||
|
50% { opacity:0.65; transform:scale(0.97); }
|
||||||
|
}
|
||||||
|
.com-tap-sub {
|
||||||
|
font-family:'Nunito',sans-serif;
|
||||||
|
font-size:0.75rem; font-weight:800;
|
||||||
|
color:rgba(255,255,255,0.35); text-align:center; margin-bottom:1.5rem;
|
||||||
|
letter-spacing:0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shaking text ── */
|
||||||
|
.com-shake-text {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:1.1rem; font-weight:900; color:#fbbf24;
|
||||||
|
text-align:center; margin-bottom:0.3rem;
|
||||||
|
animation:comShakeText 0.3s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes comShakeText {
|
||||||
|
from{ transform:translateX(-3px); }
|
||||||
|
to { transform:translateX(3px); }
|
||||||
|
}
|
||||||
|
.com-shake-dots {
|
||||||
|
font-size:1.4rem; text-align:center; margin-bottom:1.5rem;
|
||||||
|
animation:comShakeText 0.25s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reward rows ── */
|
||||||
|
.com-rewards-title {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:0.65rem; font-weight:700; letter-spacing:0.18em;
|
||||||
|
text-transform:uppercase; color:rgba(251,191,36,0.5);
|
||||||
|
text-align:center; margin-bottom:0.85rem;
|
||||||
|
}
|
||||||
|
.com-rewards { display:flex; flex-direction:column; gap:0.55rem; width:100%; margin-bottom:1.1rem; }
|
||||||
|
.com-reward-row {
|
||||||
|
display:flex; align-items:center; gap:0.85rem;
|
||||||
|
padding:0.8rem 1rem;
|
||||||
|
background:rgba(255,255,255,0.04);
|
||||||
|
border:1px solid rgba(251,191,36,0.18);
|
||||||
|
border-radius:16px;
|
||||||
|
animation:comRowIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes comRowIn {
|
||||||
|
from{ opacity:0; transform:translateY(18px) scale(0.88); }
|
||||||
|
to { opacity:1; transform:translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
.com-reward-icon { font-size:1.5rem; flex-shrink:0; filter:drop-shadow(0 2px 8px rgba(251,191,36,0.5)); }
|
||||||
|
.com-reward-lbl {
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:0.65rem; font-weight:700; letter-spacing:0.1em; text-transform:uppercase;
|
||||||
|
color:rgba(255,255,255,0.4); margin-bottom:0.12rem;
|
||||||
|
}
|
||||||
|
.com-reward-val {
|
||||||
|
font-family:'Nunito',sans-serif;
|
||||||
|
font-size:1.05rem; font-weight:900;
|
||||||
|
color:#fbbf24;
|
||||||
|
text-shadow:0 0 16px rgba(251,191,36,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special XP row highlight */
|
||||||
|
.com-reward-row.xp-row {
|
||||||
|
border-color:rgba(251,191,36,0.35);
|
||||||
|
background:rgba(251,191,36,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTA button ── */
|
||||||
|
.com-cta {
|
||||||
|
width:100%; padding:1rem;
|
||||||
|
background:linear-gradient(135deg,#fbbf24,#f59e0b);
|
||||||
|
border:none; border-radius:16px; cursor:pointer;
|
||||||
|
font-family:'Cinzel',serif;
|
||||||
|
font-size:1rem; font-weight:900; color:#1a0800;
|
||||||
|
letter-spacing:0.05em;
|
||||||
|
box-shadow:0 5px 0 #b45309, 0 8px 24px rgba(251,191,36,0.4);
|
||||||
|
transition:all 0.12s ease;
|
||||||
|
animation:comRowIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
.com-cta:hover { transform:translateY(-3px); box-shadow:0 8px 0 #b45309, 0 14px 32px rgba(251,191,36,0.5); }
|
||||||
|
.com-cta:active { transform:translateY(2px); box-shadow:0 3px 0 #b45309; }
|
||||||
|
|
||||||
|
/* ── Skip hint ── */
|
||||||
|
.com-skip {
|
||||||
|
position:absolute; bottom:1.5rem;
|
||||||
|
font-family:'Nunito',sans-serif;
|
||||||
|
font-size:0.65rem; font-weight:700;
|
||||||
|
color:rgba(255,255,255,0.2); letter-spacing:0.1em;
|
||||||
|
text-transform:uppercase; cursor:pointer; z-index:7;
|
||||||
|
transition:color 0.2s;
|
||||||
|
}
|
||||||
|
.com-skip:hover { color:rgba(255,255,255,0.5); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
const PARTICLE_COLORS = [
|
||||||
|
"#fbbf24",
|
||||||
|
"#f59e0b",
|
||||||
|
"#ef4444",
|
||||||
|
"#ec4899",
|
||||||
|
"#a855f7",
|
||||||
|
"#6366f1",
|
||||||
|
"#22d3ee",
|
||||||
|
"#4ade80",
|
||||||
|
"#fb923c",
|
||||||
|
];
|
||||||
|
const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"];
|
||||||
|
const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"];
|
||||||
|
|
||||||
|
// Rays at evenly spaced angles
|
||||||
|
const RAYS = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
angle: `${(i / 12) * 360}deg`,
|
||||||
|
delay: `${i * 0.04}s`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Burst rings
|
||||||
|
const BURST_RINGS = [
|
||||||
|
{ id: 0, size: "3", dur: "0.7s", delay: "0s" },
|
||||||
|
{ id: 1, size: "5", dur: "0.9s", delay: "0.1s" },
|
||||||
|
{ id: 2, size: "8", dur: "1.1s", delay: "0.2s" },
|
||||||
|
{ id: 3, size: "12", dur: "1.4s", delay: "0.3s" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Stars in background — stable between renders
|
||||||
|
const STARS = Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
w: 1 + ((i * 7) % 3),
|
||||||
|
top: `${(i * 17 + 3) % 95}%`,
|
||||||
|
left: `${(i * 23 + 11) % 97}%`,
|
||||||
|
dur: `${2 + ((i * 3) % 4)}s`,
|
||||||
|
delay: `${(i * 7) % 3}s`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sparkles floating around the revealed card
|
||||||
|
const SPARKLES = Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
emoji: SPARKLE_EMOJIS[i % 4],
|
||||||
|
size: `${0.9 + (i % 3) * 0.35}rem`,
|
||||||
|
top: `${10 + ((i * 12) % 75)}%`,
|
||||||
|
left: `${5 + ((i * 14) % 85)}%`,
|
||||||
|
dur: `${2 + (i % 3) * 1.2}s`,
|
||||||
|
delay: `${i * 0.3}s`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Phase = "idle" | "shaking" | "opening" | "revealed";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: QuestNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChestOpenModal = ({ node, onClose }: Props) => {
|
||||||
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
|
const [showXP, setShowXP] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Stable particle arrays computed once per mount
|
||||||
|
const particles = useRef(
|
||||||
|
Array.from({ length: 55 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
color: PARTICLE_COLORS[i % PARTICLE_COLORS.length],
|
||||||
|
w: 3 + (i % 3) * 4,
|
||||||
|
tx: ((i % 2 === 0 ? 1 : -1) * (40 + i * 7)) % 200,
|
||||||
|
ty: -(30 + ((i * 11) % 190)),
|
||||||
|
rot: ((i * 23) % 720) - 360,
|
||||||
|
dur: `${0.7 + ((i * 7) % 10) / 10}s`,
|
||||||
|
delay: `${((i * 3) % 8) / 30}s`,
|
||||||
|
})),
|
||||||
|
).current;
|
||||||
|
|
||||||
|
const coins = useRef(
|
||||||
|
Array.from({ length: 18 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
emoji: COIN_EMOJIS[i % COIN_EMOJIS.length],
|
||||||
|
size: `${1 + (i % 3) * 0.45}rem`,
|
||||||
|
tx: (i % 2 === 0 ? 1 : -1) * (30 + ((i * 9) % 180)),
|
||||||
|
ty: -(40 + ((i * 13) % 200)),
|
||||||
|
rot: ((i * 31) % 540) - 270,
|
||||||
|
dur: `${0.75 + ((i * 7) % 8) / 10}s`,
|
||||||
|
delay: `${((i * 5) % 10) / 30}s`,
|
||||||
|
})),
|
||||||
|
).current;
|
||||||
|
|
||||||
|
const tap = () => {
|
||||||
|
if (phase !== "idle") return;
|
||||||
|
setPhase("shaking");
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setPhase("opening");
|
||||||
|
setShowXP(true);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setShowXP(false);
|
||||||
|
setPhase("revealed");
|
||||||
|
}, 1800);
|
||||||
|
}, 650);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rewards = [
|
||||||
|
{
|
||||||
|
key: "xp",
|
||||||
|
cls: "xp-row",
|
||||||
|
icon: "⚡",
|
||||||
|
lbl: "XP Gained",
|
||||||
|
val: `+${node.reward.xp} XP`,
|
||||||
|
delay: "0.05s",
|
||||||
|
},
|
||||||
|
...(node.reward.title
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
cls: "",
|
||||||
|
icon: "🏴☠️",
|
||||||
|
lbl: "Crew Title",
|
||||||
|
val: node.reward.title,
|
||||||
|
delay: "0.15s",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(node.reward.itemLabel
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "item",
|
||||||
|
cls: "",
|
||||||
|
icon: "🎁",
|
||||||
|
lbl: "Item",
|
||||||
|
val: node.reward.itemLabel,
|
||||||
|
delay: "0.25s",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const chestClass =
|
||||||
|
phase === "idle"
|
||||||
|
? "idle"
|
||||||
|
: phase === "shaking"
|
||||||
|
? "shake"
|
||||||
|
: phase === "opening"
|
||||||
|
? "opening"
|
||||||
|
: "";
|
||||||
|
const chestEmoji = phase === "opening" || phase === "revealed" ? "📬" : "📦";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="com-overlay" onClick={phase === "idle" ? tap : undefined}>
|
||||||
|
<style>{S}</style>
|
||||||
|
|
||||||
|
{/* Background */}
|
||||||
|
<div className="com-bg" />
|
||||||
|
|
||||||
|
{/* Stars */}
|
||||||
|
{STARS.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="com-star"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: s.w,
|
||||||
|
height: s.w,
|
||||||
|
top: s.top,
|
||||||
|
left: s.left,
|
||||||
|
"--sdur": s.dur,
|
||||||
|
"--sdelay": s.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Crepuscular rays (appear on open) */}
|
||||||
|
{(phase === "opening" || phase === "revealed") && (
|
||||||
|
<div className="com-rays">
|
||||||
|
{RAYS.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="com-ray"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--angle": r.angle,
|
||||||
|
"--raydelay": r.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Burst rings */}
|
||||||
|
{(phase === "opening" || phase === "revealed") && (
|
||||||
|
<div className="com-burst">
|
||||||
|
{BURST_RINGS.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="com-burst-ring"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: "100px",
|
||||||
|
height: "100px",
|
||||||
|
"--brs": r.size,
|
||||||
|
"--brdur": r.dur,
|
||||||
|
"--brdelay": r.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Particle explosion */}
|
||||||
|
{(phase === "opening" || phase === "revealed") && (
|
||||||
|
<>
|
||||||
|
{particles.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="com-particle"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: p.w,
|
||||||
|
height: p.w,
|
||||||
|
background: p.color,
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
"--ptx": `${p.tx}px`,
|
||||||
|
"--pty": `${p.ty}px`,
|
||||||
|
"--prot": `${p.rot}deg`,
|
||||||
|
"--pdur": p.dur,
|
||||||
|
"--pdelay": p.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{coins.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="com-coin"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
"--csize": c.size,
|
||||||
|
"--ctx": `${c.tx}px`,
|
||||||
|
"--cty": `${c.ty}px`,
|
||||||
|
"--crot": `${c.rot}deg`,
|
||||||
|
"--cdur": c.dur,
|
||||||
|
"--cdelay": c.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{c.emoji}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Floating sparkles in revealed state */}
|
||||||
|
{phase === "revealed" &&
|
||||||
|
SPARKLES.map((sp) => (
|
||||||
|
<div
|
||||||
|
key={sp.id}
|
||||||
|
className="com-sparkle"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
top: sp.top,
|
||||||
|
left: sp.left,
|
||||||
|
"--spsize": sp.size,
|
||||||
|
"--spdur": sp.dur,
|
||||||
|
"--spdelay": sp.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sp.emoji}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* XP blast */}
|
||||||
|
{showXP && <div className="com-xp-blast">+{node.reward.xp} XP</div>}
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="com-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="com-card-inner">
|
||||||
|
<p className="com-label">
|
||||||
|
{phase === "revealed" ? "⚓ Treasure Claimed" : "📦 Treasure Chest"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Chest */}
|
||||||
|
<div
|
||||||
|
className="com-chest-area"
|
||||||
|
onClick={phase === "idle" ? tap : undefined}
|
||||||
|
style={{ cursor: phase === "idle" ? "pointer" : "default" }}
|
||||||
|
>
|
||||||
|
{phase !== "revealed" && <div className="com-glow-pad" />}
|
||||||
|
{phase !== "revealed" && (
|
||||||
|
<div className="com-orbit">
|
||||||
|
<div className="com-orbit-dot" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className={`com-chest ${chestClass}`}>{chestEmoji}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase content */}
|
||||||
|
{phase === "idle" && (
|
||||||
|
<>
|
||||||
|
<p className="com-tap-title">Tap to Open!</p>
|
||||||
|
<p className="com-tap-sub">YOUR HARD WORK HAS PAID OFF, PIRATE</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "shaking" && (
|
||||||
|
<>
|
||||||
|
<p className="com-shake-text">The chest stirs...</p>
|
||||||
|
<p className="com-shake-dots">⚡ ⚡ ⚡</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "revealed" && (
|
||||||
|
<>
|
||||||
|
<p className="com-rewards-title">⚓ Spoils of Victory</p>
|
||||||
|
<div className="com-rewards">
|
||||||
|
{rewards.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.key}
|
||||||
|
className={`com-reward-row ${r.cls}`}
|
||||||
|
style={{ animationDelay: r.delay }}
|
||||||
|
>
|
||||||
|
<span className="com-reward-icon">{r.icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="com-reward-lbl">{r.lbl}</p>
|
||||||
|
<p className="com-reward-val">{r.val}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="com-cta"
|
||||||
|
style={{ animationDelay: rewards.length * 0.1 + "s" }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
⚓ Set Sail
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip link for impatient pirates */}
|
||||||
|
{phase === "revealed" && (
|
||||||
|
<p className="com-skip" onClick={onClose}>
|
||||||
|
tap anywhere to continue
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1076
src/components/QuestNodeModal.tsx
Normal file
1076
src/components/QuestNodeModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
507
src/components/QuestProgressCard.tsx
Normal file
507
src/components/QuestProgressCard.tsx
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { QuestNode, QuestArc } from "../types/quest";
|
||||||
|
import { CREW_RANKS } from "../types/quest";
|
||||||
|
import {
|
||||||
|
useQuestStore,
|
||||||
|
getQuestSummary,
|
||||||
|
getCrewRank,
|
||||||
|
} from "../stores/useQuestStore";
|
||||||
|
import { ChestOpenModal } from "./ChestOpenModal";
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
/* ══ CARD SHELL ══ */
|
||||||
|
.qpc2-card {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
|
||||||
|
border: 1.5px solid rgba(251,191,36,0.2);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0,0,0,0.35),
|
||||||
|
0 0 0 1px rgba(255,255,255,0.04) inset,
|
||||||
|
0 1px 0 rgba(255,255,255,0.08) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated sea shimmer behind everything */
|
||||||
|
.qpc2-sea {
|
||||||
|
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(105deg, transparent 0%, transparent 55%,
|
||||||
|
rgba(56,189,248,0.022) 56%, transparent 57%),
|
||||||
|
repeating-linear-gradient(75deg, transparent 0%, transparent 70%,
|
||||||
|
rgba(56,189,248,0.014) 71%, transparent 72%);
|
||||||
|
background-size: 300% 300%, 250% 250%;
|
||||||
|
animation: qpc2Sea 12s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes qpc2Sea {
|
||||||
|
0% { background-position: 0% 0%, 100% 0%; }
|
||||||
|
100% { background-position: 100% 100%, 0% 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faint gold orb top-right */
|
||||||
|
.qpc2-orb {
|
||||||
|
position: absolute; top: -40px; right: -30px;
|
||||||
|
width: 160px; height: 160px; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(251,191,36,0.14) 0%, transparent 70%);
|
||||||
|
pointer-events: none; z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ RANK HERO (always visible) ══ */
|
||||||
|
.qpc2-hero {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
padding: 1rem 1.1rem 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s ease;
|
||||||
|
}
|
||||||
|
.qpc2-hero:hover { background: rgba(255,255,255,0.025); }
|
||||||
|
|
||||||
|
.qpc2-hero-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.qpc2-hero-left { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
|
||||||
|
.qpc2-hero-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Rank badge icon */
|
||||||
|
.qpc2-rank-icon {
|
||||||
|
width: 44px; height: 44px; border-radius: 14px; flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, #1e0e4a, #3730a3);
|
||||||
|
border: 1.5px solid rgba(251,191,36,0.35);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
box-shadow: 0 4px 0 rgba(30,14,74,0.7), 0 0 16px rgba(251,191,36,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qpc2-rank-label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.78rem; font-weight: 700;
|
||||||
|
color: rgba(255,255,255,0.45); letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
.qpc2-rank-name {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 1.05rem; font-weight: 700;
|
||||||
|
color: #fbbf24;
|
||||||
|
text-shadow: 0 0 18px rgba(251,191,36,0.45);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rank progress bar */
|
||||||
|
.qpc2-rank-bar-wrap {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.qpc2-rank-bar-track {
|
||||||
|
flex: 1; height: 5px; border-radius: 100px;
|
||||||
|
background: rgba(255,255,255,0.1); overflow: hidden;
|
||||||
|
}
|
||||||
|
.qpc2-rank-bar-fill {
|
||||||
|
height: 100%; border-radius: 100px;
|
||||||
|
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
||||||
|
box-shadow: 0 0 8px rgba(251,191,36,0.5);
|
||||||
|
transition: width 0.7s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.qpc2-rank-bar-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.6rem; font-weight: 700;
|
||||||
|
color: rgba(255,255,255,0.35); white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats row */
|
||||||
|
.qpc2-stats {
|
||||||
|
display: flex; gap: 0.5rem; margin-top: 0.75rem;
|
||||||
|
padding-top: 0.7rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.07);
|
||||||
|
}
|
||||||
|
.qpc2-stat {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.qpc2-stat-val {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.95rem; font-weight: 900; color: #fbbf24;
|
||||||
|
}
|
||||||
|
.qpc2-stat-lbl {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.56rem; font-weight: 700;
|
||||||
|
color: rgba(255,255,255,0.35); text-align: center;
|
||||||
|
letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.qpc2-stat-div {
|
||||||
|
width: 1px; background: rgba(255,255,255,0.08); margin: 0.1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chest badge */
|
||||||
|
.qpc2-chest-badge {
|
||||||
|
display: flex; align-items: center; gap: 0.22rem;
|
||||||
|
padding: 0.22rem 0.6rem;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.65rem; font-weight: 900; color: #1a0800;
|
||||||
|
box-shadow: 0 2px 0 #d97706, 0 0 10px rgba(251,191,36,0.35);
|
||||||
|
animation: qpc2ChestPop 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes qpc2ChestPop {
|
||||||
|
0%,100%{ transform: scale(1); }
|
||||||
|
50% { transform: scale(1.07); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand chevron */
|
||||||
|
.qpc2-chevron {
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s;
|
||||||
|
}
|
||||||
|
.qpc2-chevron.open { transform: rotate(180deg); color: #fbbf24; }
|
||||||
|
|
||||||
|
/* ══ COLLAPSIBLE BODY ══ */
|
||||||
|
.qpc2-body {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
transition: max-height 0.4s cubic-bezier(0.4,0,0.2,1);
|
||||||
|
}
|
||||||
|
.qpc2-body.open { max-height: 600px; }
|
||||||
|
|
||||||
|
.qpc2-divider {
|
||||||
|
height: 1px; background: rgba(255,255,255,0.07); margin: 0 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ QUEST ROWS ══ */
|
||||||
|
.qpc2-quest-list { display: flex; flex-direction: column; padding: 0.5rem 0; }
|
||||||
|
|
||||||
|
.qpc2-quest-row {
|
||||||
|
display: flex; align-items: center; gap: 0.7rem;
|
||||||
|
padding: 0.75rem 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.qpc2-quest-row:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
|
/* Left accent line = arc colour */
|
||||||
|
.qpc2-quest-row::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 16%; bottom: 16%;
|
||||||
|
width: 3px; border-radius: 0 3px 3px 0;
|
||||||
|
background: var(--ac);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qpc2-quest-icon {
|
||||||
|
width: 38px; height: 38px; border-radius: 12px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1.5px solid rgba(255,255,255,0.08);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.qpc2-quest-row:hover .qpc2-quest-icon { transform: scale(1.1) rotate(-5deg); }
|
||||||
|
.qpc2-quest-icon.claimable {
|
||||||
|
background: rgba(251,191,36,0.12);
|
||||||
|
border-color: rgba(251,191,36,0.4);
|
||||||
|
animation: qpc2Wiggle 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes qpc2Wiggle {
|
||||||
|
0%,100%{ transform: rotate(0deg); }
|
||||||
|
25% { transform: rotate(-8deg) scale(1.06); }
|
||||||
|
75% { transform: rotate(8deg) scale(1.06); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qpc2-quest-body { flex: 1; min-width: 0; }
|
||||||
|
.qpc2-quest-arc {
|
||||||
|
font-size: 0.57rem; font-weight: 800; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--ac);
|
||||||
|
margin-bottom: 0.08rem;
|
||||||
|
}
|
||||||
|
.qpc2-quest-title {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.9);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
margin-bottom: 0.28rem;
|
||||||
|
}
|
||||||
|
.qpc2-mini-track {
|
||||||
|
height: 4px; background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 100px; overflow: hidden; margin-bottom: 0.18rem;
|
||||||
|
}
|
||||||
|
.qpc2-mini-fill {
|
||||||
|
height: 100%; border-radius: 100px;
|
||||||
|
background: var(--ac);
|
||||||
|
box-shadow: 0 0 5px color-mix(in srgb, var(--ac) 55%, transparent);
|
||||||
|
transition: width 0.5s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.qpc2-mini-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.58rem; font-weight: 700; color: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
.qpc2-claimable-label {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.62rem; font-weight: 700; color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Claim button */
|
||||||
|
.qpc2-claim-btn {
|
||||||
|
padding: 0.32rem 0.7rem; border: none; border-radius: 100px; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.65rem; font-weight: 900; color: #1a0800;
|
||||||
|
box-shadow: 0 2px 0 #d97706, 0 3px 8px rgba(251,191,36,0.25);
|
||||||
|
flex-shrink: 0; white-space: nowrap;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
}
|
||||||
|
.qpc2-claim-btn:hover { transform: translateY(-1px); box-shadow: 0 3px 0 #d97706; }
|
||||||
|
.qpc2-claim-btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
/* ══ FOOTER LINK ══ */
|
||||||
|
.qpc2-footer {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
||||||
|
padding: 0.65rem 1.1rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.07);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.qpc2-footer:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.qpc2-footer-label {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 0.72rem; font-weight: 800;
|
||||||
|
color: rgba(251,191,36,0.7);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.qpc2-footer:hover .qpc2-footer-label { color: #fbbf24; }
|
||||||
|
|
||||||
|
/* ══ EMPTY STATE ══ */
|
||||||
|
.qpc2-empty {
|
||||||
|
padding: 1.25rem 1.1rem; text-align: center;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.qpc2-empty-title {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 0.88rem; font-weight: 700; color: rgba(255,255,255,0.55);
|
||||||
|
}
|
||||||
|
.qpc2-empty-sub {
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
font-size: 0.68rem; font-weight: 600; color: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function getActiveQuests(arcs: QuestArc[]) {
|
||||||
|
const results: { node: QuestNode; arc: QuestArc }[] = [];
|
||||||
|
for (const arc of arcs) {
|
||||||
|
for (const node of arc.nodes) {
|
||||||
|
if (node.status === "claimable" || node.status === "active") {
|
||||||
|
results.push({ node, arc });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Claimable first, then active; max 2 shown
|
||||||
|
results.sort((a, b) => {
|
||||||
|
if (a.node.status === "claimable" && b.node.status !== "claimable")
|
||||||
|
return -1;
|
||||||
|
if (b.node.status === "claimable" && a.node.status !== "claimable")
|
||||||
|
return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return results.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
interface Props {
|
||||||
|
onViewAll?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuestProgressCard = ({ onViewAll }: Props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const arcs = useQuestStore((s) => s.arcs);
|
||||||
|
const claimNode = useQuestStore((s) => s.claimNode);
|
||||||
|
|
||||||
|
const summary = getQuestSummary(arcs);
|
||||||
|
const rank = getCrewRank(arcs);
|
||||||
|
const activeQuests = getActiveQuests(arcs);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [claimingNode, setClaimingNode] = useState<{
|
||||||
|
node: QuestNode;
|
||||||
|
arcId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleViewAll = () => {
|
||||||
|
if (onViewAll) onViewAll();
|
||||||
|
else navigate("/student/quests");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClaim = (node: QuestNode, arcId: string) => {
|
||||||
|
setClaimingNode({ node, arcId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChestClose = () => {
|
||||||
|
if (!claimingNode) return;
|
||||||
|
claimNode(claimingNode.arcId, claimingNode.node.id);
|
||||||
|
setClaimingNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Next rank label
|
||||||
|
const nextRankLabel = rank.next
|
||||||
|
? `${Math.round(rank.progressToNext * 100)}% to ${rank.next.label}`
|
||||||
|
: "Max rank reached";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
|
<div className="qpc2-card">
|
||||||
|
{/* Atmosphere layers */}
|
||||||
|
<div className="qpc2-sea" />
|
||||||
|
<div className="qpc2-orb" />
|
||||||
|
|
||||||
|
{/* ── Rank hero (always visible, tap to expand) ── */}
|
||||||
|
<div className="qpc2-hero" onClick={() => setOpen((o) => !o)}>
|
||||||
|
<div className="qpc2-hero-row">
|
||||||
|
<div className="qpc2-hero-left">
|
||||||
|
<div className="qpc2-rank-icon">{rank.emoji}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p className="qpc2-rank-label">Crew Rank</p>
|
||||||
|
<p className="qpc2-rank-name">{rank.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="qpc2-hero-right">
|
||||||
|
{summary.claimableNodes > 0 && (
|
||||||
|
<div className="qpc2-chest-badge">
|
||||||
|
📦 {summary.claimableNodes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={`qpc2-chevron${open ? " open" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rank progress bar */}
|
||||||
|
<div className="qpc2-rank-bar-wrap">
|
||||||
|
<div className="qpc2-rank-bar-track">
|
||||||
|
<div
|
||||||
|
className="qpc2-rank-bar-fill"
|
||||||
|
style={{ width: `${Math.round(rank.progressToNext * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="qpc2-rank-bar-label">{nextRankLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats strip */}
|
||||||
|
<div className="qpc2-stats">
|
||||||
|
{[
|
||||||
|
{ val: `${summary.earnedXP}`, lbl: "XP Earned" },
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
val: `${summary.completedNodes}/${summary.totalNodes}`,
|
||||||
|
lbl: "Quests Done",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
val: `${summary.arcsCompleted}/${summary.totalArcs}`,
|
||||||
|
lbl: "Arcs",
|
||||||
|
},
|
||||||
|
].map((item, i) =>
|
||||||
|
item === null ? (
|
||||||
|
<div key={i} className="qpc2-stat-div" />
|
||||||
|
) : (
|
||||||
|
<div key={i} className="qpc2-stat">
|
||||||
|
<span className="qpc2-stat-val">{item.val}</span>
|
||||||
|
<span className="qpc2-stat-lbl">{item.lbl}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Collapsible quest list ── */}
|
||||||
|
<div className={`qpc2-body${open ? " open" : ""}`}>
|
||||||
|
<div className="qpc2-divider" />
|
||||||
|
<div className="qpc2-quest-list">
|
||||||
|
{activeQuests.length === 0 ? (
|
||||||
|
<div className="qpc2-empty">
|
||||||
|
<span style={{ fontSize: "1.75rem" }}>⚓</span>
|
||||||
|
<p className="qpc2-empty-title">All caught up, Captain!</p>
|
||||||
|
<p className="qpc2-empty-sub">
|
||||||
|
No active quests — keep sailing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeQuests.map(({ node, arc }) => {
|
||||||
|
const pct = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((node.progress / node.requirement.target) * 100),
|
||||||
|
);
|
||||||
|
const isClaimable = node.status === "claimable";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className="qpc2-quest-row"
|
||||||
|
style={{ "--ac": arc.accentColor } as React.CSSProperties}
|
||||||
|
onClick={() => !isClaimable && handleViewAll()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`qpc2-quest-icon${isClaimable ? " claimable" : ""}`}
|
||||||
|
>
|
||||||
|
{isClaimable ? "📦" : node.emoji}
|
||||||
|
</div>
|
||||||
|
<div className="qpc2-quest-body">
|
||||||
|
<p className="qpc2-quest-arc">
|
||||||
|
{arc.emoji} {arc.name}
|
||||||
|
</p>
|
||||||
|
<p className="qpc2-quest-title">{node.title}</p>
|
||||||
|
{isClaimable ? (
|
||||||
|
<p className="qpc2-claimable-label">
|
||||||
|
✨ Chest ready to open!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="qpc2-mini-track">
|
||||||
|
<div
|
||||||
|
className="qpc2-mini-fill"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="qpc2-mini-label">
|
||||||
|
{node.progress} / {node.requirement.target}{" "}
|
||||||
|
{node.requirement.label}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isClaimable ? (
|
||||||
|
<button
|
||||||
|
className="qpc2-claim-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClaim(node, arc.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open 📦
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} color="rgba(255,255,255,0.2)" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — navigate to full map */}
|
||||||
|
<div className="qpc2-footer" onClick={handleViewAll}>
|
||||||
|
<span className="qpc2-footer-label">View full quest map</span>
|
||||||
|
<ChevronRight size={14} color="rgba(251,191,36,0.7)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claimingNode && (
|
||||||
|
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
344
src/data/questData.ts
Normal file
344
src/data/questData.ts
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import type { QuestArc } from "../types/quest";
|
||||||
|
|
||||||
|
// ─── QUEST DATA ───────────────────────────────────────────────────────────────
|
||||||
|
// Replace each node's `progress` and `status` with live API values.
|
||||||
|
// Everything else (titles, flavour, rewards) is content — edit freely.
|
||||||
|
|
||||||
|
export const QUEST_ARCS: QuestArc[] = [
|
||||||
|
// ── ARC 1: The Calm Seas ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "east_blue",
|
||||||
|
name: "The Calm Seas",
|
||||||
|
subtitle: "Every great voyage begins at shore",
|
||||||
|
emoji: "🌊",
|
||||||
|
accentColor: "#0ea5e9",
|
||||||
|
accentDark: "#0369a1",
|
||||||
|
bgFrom: "#0c4a6e",
|
||||||
|
bgTo: "#075985",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "eb_1",
|
||||||
|
title: "First Steps",
|
||||||
|
flavourText:
|
||||||
|
'"I\'ll become the greatest sailor who ever lived!" — Every legend begins with a single step.',
|
||||||
|
islandName: "Hawthorn Cove",
|
||||||
|
emoji: "🏝️",
|
||||||
|
requirement: {
|
||||||
|
type: "questions",
|
||||||
|
target: 10,
|
||||||
|
label: "questions answered",
|
||||||
|
},
|
||||||
|
progress: 10,
|
||||||
|
status: "completed",
|
||||||
|
reward: { xp: 50, title: "Cabin Hand" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_2",
|
||||||
|
title: "Cast Off",
|
||||||
|
flavourText:
|
||||||
|
'"The sea doesn\'t care who you were — only who you become." Chart your course.',
|
||||||
|
islandName: "Redmast Port",
|
||||||
|
emoji: "⚓",
|
||||||
|
requirement: {
|
||||||
|
type: "sessions",
|
||||||
|
target: 3,
|
||||||
|
label: "practice sessions",
|
||||||
|
},
|
||||||
|
progress: 3,
|
||||||
|
status: "completed",
|
||||||
|
reward: { xp: 75 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_3",
|
||||||
|
title: "The Tangerine Coast",
|
||||||
|
flavourText:
|
||||||
|
'"Even alone, I protect my crew." Keep your streak burning bright.',
|
||||||
|
islandName: "Citrus Bay",
|
||||||
|
emoji: "🍊",
|
||||||
|
requirement: { type: "streak", target: 3, label: "day streak" },
|
||||||
|
progress: 3,
|
||||||
|
status: "completed",
|
||||||
|
reward: {
|
||||||
|
xp: 100,
|
||||||
|
item: "streak_shield",
|
||||||
|
itemLabel: "Streak Shield ×1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_4",
|
||||||
|
title: "The Fog Village",
|
||||||
|
flavourText:
|
||||||
|
'"I\'ve fooled everyone — except myself." Prove yourself across new territory.',
|
||||||
|
islandName: "Mistholm Village",
|
||||||
|
emoji: "🌿",
|
||||||
|
requirement: { type: "topics", target: 5, label: "topics practiced" },
|
||||||
|
progress: 3,
|
||||||
|
status: "claimable",
|
||||||
|
reward: { xp: 125, title: "Deckhand" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_5",
|
||||||
|
title: "The Floating Galley",
|
||||||
|
flavourText:
|
||||||
|
'"Nothing happened." Cut through the noise with razor accuracy.',
|
||||||
|
islandName: "The Iron Kitchen",
|
||||||
|
emoji: "🍖",
|
||||||
|
requirement: {
|
||||||
|
type: "accuracy",
|
||||||
|
target: 75,
|
||||||
|
label: "% accuracy (any session)",
|
||||||
|
},
|
||||||
|
progress: 58,
|
||||||
|
status: "active",
|
||||||
|
reward: {
|
||||||
|
xp: 150,
|
||||||
|
item: "xp_boost",
|
||||||
|
itemLabel: "2× XP Boost (1 session)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "eb_6",
|
||||||
|
title: "The Sharkfin Strait",
|
||||||
|
flavourText:
|
||||||
|
'"This is my dream!" Conquer the Calm Seas before the Grand Voyage beckons.',
|
||||||
|
islandName: "Sharkfin Strait",
|
||||||
|
emoji: "🦈",
|
||||||
|
requirement: {
|
||||||
|
type: "questions",
|
||||||
|
target: 100,
|
||||||
|
label: "questions answered",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 300, title: "First Mate" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── ARC 2: The Amber Wastes ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "alabasta",
|
||||||
|
name: "The Amber Wastes",
|
||||||
|
subtitle: "Through the desert sands, to glory",
|
||||||
|
emoji: "🏜️",
|
||||||
|
accentColor: "#f59e0b",
|
||||||
|
accentDark: "#b45309",
|
||||||
|
bgFrom: "#78350f",
|
||||||
|
bgTo: "#92400e",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "al_1",
|
||||||
|
title: "Crossing the Mirrorlake",
|
||||||
|
flavourText:
|
||||||
|
'"A true sailor never makes excuses after losing." Enter the warzone.',
|
||||||
|
islandName: "Mirrorlake Basin",
|
||||||
|
emoji: "💧",
|
||||||
|
requirement: {
|
||||||
|
type: "sessions",
|
||||||
|
target: 5,
|
||||||
|
label: "practice sessions",
|
||||||
|
},
|
||||||
|
progress: 5,
|
||||||
|
status: "completed",
|
||||||
|
reward: { xp: 150 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_2",
|
||||||
|
title: "The Sand March",
|
||||||
|
flavourText:
|
||||||
|
'"They underestimated us." Grind through the scorching heat.',
|
||||||
|
islandName: "The Amber Dunes",
|
||||||
|
emoji: "🌵",
|
||||||
|
requirement: {
|
||||||
|
type: "questions",
|
||||||
|
target: 50,
|
||||||
|
label: "questions answered",
|
||||||
|
},
|
||||||
|
progress: 50,
|
||||||
|
status: "completed",
|
||||||
|
reward: {
|
||||||
|
xp: 175,
|
||||||
|
item: "xp_boost",
|
||||||
|
itemLabel: "1.5× XP Boost (1 session)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_3",
|
||||||
|
title: "The Sunstone Palace",
|
||||||
|
flavourText: '"I refuse to let my crew fall!" Climb the leaderboard.',
|
||||||
|
islandName: "Sunstone City",
|
||||||
|
emoji: "🏰",
|
||||||
|
requirement: {
|
||||||
|
type: "leaderboard",
|
||||||
|
target: 10,
|
||||||
|
label: "leaderboard rank",
|
||||||
|
},
|
||||||
|
progress: 22,
|
||||||
|
status: "active",
|
||||||
|
reward: { xp: 250, title: "Corsair" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_4",
|
||||||
|
title: "Blades in the Bazaar",
|
||||||
|
flavourText:
|
||||||
|
'"I\'ll cut through iron." Maintain brutal accuracy under pressure.',
|
||||||
|
islandName: "Bazaar Streets",
|
||||||
|
emoji: "⚔️",
|
||||||
|
requirement: {
|
||||||
|
type: "accuracy",
|
||||||
|
target: 85,
|
||||||
|
label: "% accuracy (any session)",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: {
|
||||||
|
xp: 300,
|
||||||
|
item: "streak_shield",
|
||||||
|
itemLabel: "Streak Shield ×2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_5",
|
||||||
|
title: "The Warlord Falls",
|
||||||
|
flavourText:
|
||||||
|
"\"I'm not dying here, partner.\" Prove you're worthy of the Wastes.",
|
||||||
|
islandName: "The Throne Dune",
|
||||||
|
emoji: "👑",
|
||||||
|
requirement: { type: "streak", target: 7, label: "day streak" },
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 400, title: "Corsair" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "al_6",
|
||||||
|
title: "The Princess's Farewell",
|
||||||
|
flavourText:
|
||||||
|
'"Even if our paths split, you\'ll always sail with my crew." The arc is complete.',
|
||||||
|
islandName: "Mirrorlake Harbour",
|
||||||
|
emoji: "🌅",
|
||||||
|
requirement: { type: "xp", target: 1000, label: "total XP earned" },
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 500, title: "Sea Emperor" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── ARC 3: The Sky Reaches ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "skypiea",
|
||||||
|
name: "The Sky Reaches",
|
||||||
|
subtitle: "Ascend to the island above the clouds",
|
||||||
|
emoji: "☁️",
|
||||||
|
accentColor: "#a855f7",
|
||||||
|
accentDark: "#7c3aed",
|
||||||
|
bgFrom: "#3b0764",
|
||||||
|
bgTo: "#4c1d95",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "sk_1",
|
||||||
|
title: "The Skyward Torrent",
|
||||||
|
flavourText:
|
||||||
|
'"The sky island is real!" Believe it — launch yourself upward.',
|
||||||
|
islandName: "Upper Cloudreach",
|
||||||
|
emoji: "🌤️",
|
||||||
|
requirement: {
|
||||||
|
type: "topics",
|
||||||
|
target: 3,
|
||||||
|
label: "topics at 70%+ accuracy",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 200 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_2",
|
||||||
|
title: "The Trial of Storms",
|
||||||
|
flavourText:
|
||||||
|
'"Follow the wind, follow the stars." Navigate every corner of the cloudscape.',
|
||||||
|
islandName: "The Tempest Ordeal",
|
||||||
|
emoji: "🎯",
|
||||||
|
requirement: {
|
||||||
|
type: "topics",
|
||||||
|
target: 8,
|
||||||
|
label: "distinct topics practiced",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: {
|
||||||
|
xp: 250,
|
||||||
|
item: "xp_boost",
|
||||||
|
itemLabel: "2× XP Boost (2 sessions)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_3",
|
||||||
|
title: "The Sky God's Wrath",
|
||||||
|
flavourText: '"I am the heavens." Are you good enough to defy a deity?',
|
||||||
|
islandName: "The Celestial Ark",
|
||||||
|
emoji: "⚡",
|
||||||
|
requirement: {
|
||||||
|
type: "accuracy",
|
||||||
|
target: 90,
|
||||||
|
label: "% accuracy (any session)",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 400, title: "Sea Emperor" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_4",
|
||||||
|
title: "The Ancient Bell",
|
||||||
|
flavourText:
|
||||||
|
'"I hear the torrent calling." Ring the bell — make history echo.',
|
||||||
|
islandName: "The Cloudvine Spire",
|
||||||
|
emoji: "🔔",
|
||||||
|
requirement: {
|
||||||
|
type: "questions",
|
||||||
|
target: 250,
|
||||||
|
label: "questions answered",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: {
|
||||||
|
xp: 500,
|
||||||
|
item: "streak_shield",
|
||||||
|
itemLabel: "Streak Shield ×3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_5",
|
||||||
|
title: "The Gilded Ruins",
|
||||||
|
flavourText:
|
||||||
|
'"THE GREAT CAPTAIN WAS HERE." Touch the treasure that all legends sought.',
|
||||||
|
islandName: "Aureveil",
|
||||||
|
emoji: "💰",
|
||||||
|
requirement: { type: "xp", target: 3000, label: "total XP earned" },
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: { xp: 750, title: "Grand Captain" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sk_6",
|
||||||
|
title: "The Grand Captain",
|
||||||
|
flavourText:
|
||||||
|
'"This is my treasure!" You\'ve reached the summit — your target score awaits.',
|
||||||
|
islandName: "The Last Isle",
|
||||||
|
emoji: "🏴☠️",
|
||||||
|
requirement: {
|
||||||
|
type: "sessions",
|
||||||
|
target: 30,
|
||||||
|
label: "total sessions completed",
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
status: "locked",
|
||||||
|
reward: {
|
||||||
|
xp: 1000,
|
||||||
|
title: "Grand Captain",
|
||||||
|
item: "xp_boost",
|
||||||
|
itemLabel: "Permanent 1.2× XP",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
13
src/hooks/useCrewRank.ts
Normal file
13
src/hooks/useCrewRank.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { QUEST_ARCS } from "../data/questData";
|
||||||
|
|
||||||
|
// Returns the player's current crew rank, or a default if none earned yet
|
||||||
|
export function getCrewRank(arcs = QUEST_ARCS): string {
|
||||||
|
const earned = arcs
|
||||||
|
.flatMap((a) => a.nodes)
|
||||||
|
.filter((n) => n.status === "completed" && n.reward.title)
|
||||||
|
.map((n) => n.reward.title!);
|
||||||
|
|
||||||
|
// Return the last one — questData is ordered by difficulty,
|
||||||
|
// so the last earned title is always the highest rank
|
||||||
|
return earned.at(-1) ?? "Cabin Hand";
|
||||||
|
}
|
||||||
32
src/pages/ErrorPage.tsx
Normal file
32
src/pages/ErrorPage.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// src/pages/ErrorPage.tsx
|
||||||
|
import { useRouteError, isRouteErrorResponse } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ErrorPage() {
|
||||||
|
const error = useRouteError();
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
let title = "Something went wrong";
|
||||||
|
let message = "An unexpected error occurred.";
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
title = `${error.status} ${error.statusText}`;
|
||||||
|
message = error.data?.message || message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="bg-white shadow-xl rounded-2xl p-8 max-w-md text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-4">{title}</h1>
|
||||||
|
<p className="text-gray-600 mb-6">{message}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => (window.location.href = "/")}
|
||||||
|
className="px-4 py-2 bg-black text-white rounded-lg"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,6 +18,9 @@ import {
|
|||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
} from "../../components/ui/drawer";
|
} from "../../components/ui/drawer";
|
||||||
import { useExamConfigStore } from "../../stores/useExamConfigStore";
|
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) ────────────────
|
// ─── Shared blob/dot background (same as break/results screens) ────────────────
|
||||||
const DOTS = [
|
const DOTS = [
|
||||||
@ -496,7 +499,7 @@ export const Home = () => {
|
|||||||
onFocus={() => setIsSearchOpen(true)}
|
onFocus={() => setIsSearchOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<QuestProgressCard onViewAll={() => navigate("/student/quests")} />
|
||||||
{/* ── In progress ── */}
|
{/* ── In progress ── */}
|
||||||
<section className="h-anim h-anim-2">
|
<section className="h-anim h-anim-2">
|
||||||
<p className="h-section-title">📌 Pick up where you left off</p>
|
<p className="h-section-title">📌 Pick up where you left off</p>
|
||||||
|
|||||||
996
src/pages/student/QuestMap.tsx
Normal file
996
src/pages/student/QuestMap.tsx
Normal file
@ -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<string, number[]> = {
|
||||||
|
east_blue: [0, 1, 2, 0, 1, 2], // steady L→C→R march
|
||||||
|
alabasta: [2, 0, 2, 1, 0, 2], // sharp zigzag, heavy right bias
|
||||||
|
skypiea: [1, 2, 0, 2, 0, 1], // wide sweeping swings C→R→L→R→L→C
|
||||||
|
};
|
||||||
|
const COL_SEQ_DEFAULT = [0, 1, 2, 0, 1, 2];
|
||||||
|
|
||||||
|
// Card half-width / half-height for the foreign-object card
|
||||||
|
const CARD_W = 130;
|
||||||
|
const CARD_H = 195;
|
||||||
|
|
||||||
|
const islandCX = (i: number, arcId: string) => {
|
||||||
|
const seq = ARC_COL_SEQS[arcId] ?? COL_SEQ_DEFAULT;
|
||||||
|
return COL_X[seq[i % seq.length]];
|
||||||
|
};
|
||||||
|
const islandCY = (i: number) => TOP_PAD + i * ROW_GAP;
|
||||||
|
|
||||||
|
// Total SVG height
|
||||||
|
const svgHeight = (n: number) => TOP_PAD + (n - 1) * ROW_GAP + TOP_PAD + CARD_H;
|
||||||
|
|
||||||
|
// ─── Island shapes (clip-path on a 110×65 rect centred at 0,0) ───────────────
|
||||||
|
const SHAPES = [
|
||||||
|
// 0: fat round atoll
|
||||||
|
`<ellipse cx="0" cy="0" rx="57" ry="33"/>`,
|
||||||
|
// 1: tall mountain peak
|
||||||
|
`<polygon points="0,-38 28,-14 48,10 40,33 22,38 -22,38 -40,33 -48,10 -28,-14"/>`,
|
||||||
|
// 2: wide flat shoal
|
||||||
|
`<ellipse cx="0" cy="5" rx="62" ry="26"/>`,
|
||||||
|
// 3: jagged rocky reef
|
||||||
|
`<polygon points="0,-38 20,-14 50,-8 32,12 42,36 16,24 0,38 -16,24 -42,36 -32,12 -50,-8 -20,-14"/>`,
|
||||||
|
// 4: crescent (right side bites in)
|
||||||
|
`<path d="M-50,0 C-50,-34 -20,-38 0,-36 C22,-34 48,-18 50,4 C52,24 36,30 18,24 C6,20 4,10 10,4 C16,-4 26,-4 28,4 C30,12 22,18 12,16 C-4,10 -10,-8 0,-20 C12,-32 -30,-28 -50,0 Z"/>`,
|
||||||
|
// 5: teardrop/pear
|
||||||
|
`<path d="M0,-38 C18,-38 44,-18 44,8 C44,28 26,38 0,38 C-26,38 -44,28 -44,8 C-44,-18 -18,-38 0,-38 Z"/>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy:ital@0;1&display=swap');
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.qm-screen {
|
||||||
|
height: 100vh; font-family: 'Nunito', sans-serif;
|
||||||
|
position: relative; display: flex; flex-direction: column;
|
||||||
|
overflow: hidden; background: #060e1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ HEADER ══ */
|
||||||
|
.qm-header {
|
||||||
|
position: relative; z-index: 30; flex-shrink: 0;
|
||||||
|
background: rgba(4,10,24,0.94);
|
||||||
|
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(251,191,36,0.12);
|
||||||
|
padding: 1.25rem 1.25rem 0;
|
||||||
|
}
|
||||||
|
.qm-page-title {
|
||||||
|
font-family: 'Sorts Mill Goudy', serif;
|
||||||
|
font-size: 1.3rem; font-weight: 900; letter-spacing: 0.05em;
|
||||||
|
color: #fbbf24;
|
||||||
|
text-shadow: 0 0 24px rgba(251,191,36,0.5), 0 0 60px rgba(251,191,36,0.15);
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
.qm-page-sub {
|
||||||
|
font-family: 'Nunito Sans', sans-serif; font-size: 0.7rem; font-weight: 600;
|
||||||
|
color: rgba(255,255,255,0.35); margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.qm-stats-strip {
|
||||||
|
display: flex; gap: 0.4rem; overflow-x: auto;
|
||||||
|
scrollbar-width: none; padding-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.qm-stats-strip::-webkit-scrollbar { display:none; }
|
||||||
|
.qm-stat-chip {
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
padding: 0.28rem 0.7rem; flex-shrink: 0;
|
||||||
|
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
.qm-stat-val { font-size:0.76rem; font-weight:900; color:#fbbf24; }
|
||||||
|
.qm-stat-label { font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:600; color:rgba(255,255,255,0.35); }
|
||||||
|
.qm-arc-tabs {
|
||||||
|
display: flex; gap:0; overflow-x:auto; scrollbar-width:none;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.qm-arc-tabs::-webkit-scrollbar { display:none; }
|
||||||
|
.qm-arc-tab {
|
||||||
|
flex-shrink:0; display:flex; align-items:center; gap:0.4rem;
|
||||||
|
padding: 0.6rem 1rem; border:none; background:transparent; cursor:pointer;
|
||||||
|
font-family:'Nunito',sans-serif; font-weight:800; font-size:0.78rem;
|
||||||
|
color: rgba(255,255,255,0.3); border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.2s ease; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.qm-arc-tab:hover { color:rgba(255,255,255,0.6); }
|
||||||
|
.qm-arc-tab.active { color:var(--arc-accent); border-bottom-color:var(--arc-accent); }
|
||||||
|
.qm-tab-dot {
|
||||||
|
width:7px; height:7px; border-radius:50%;
|
||||||
|
background:#ef4444; box-shadow:0 0 8px #ef4444;
|
||||||
|
animation: qmDotBlink 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes qmDotBlink { 0%,100%{ opacity:1; } 50%{ opacity:0.4; } }
|
||||||
|
|
||||||
|
/* ══ SEA ══ */
|
||||||
|
.qm-sea-scroll {
|
||||||
|
flex:1; overflow-y:auto; overflow-x:hidden;
|
||||||
|
position:relative; scrollbar-width:none; -webkit-overflow-scrolling:touch;
|
||||||
|
}
|
||||||
|
.qm-sea-scroll::-webkit-scrollbar { display:none; }
|
||||||
|
.qm-sea {
|
||||||
|
position:relative; min-height:100%;
|
||||||
|
padding: 1.25rem 1.25rem 8rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 40% at 20% 15%, rgba(6,80,160,0.45) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 80% 60%, rgba(4,50,110,0.35) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 70% 40% at 50% 90%, rgba(8,120,180,0.2) 0%, transparent 50%),
|
||||||
|
linear-gradient(180deg, #071530 0%, #04101e 40%, #020a14 100%);
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.qm-sea-shimmer {
|
||||||
|
position:absolute; inset:0; pointer-events:none; z-index:0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(105deg, transparent 0%, transparent 55%, rgba(56,189,248,0.018) 56%, transparent 57%),
|
||||||
|
repeating-linear-gradient(75deg, transparent 0%, transparent 70%, rgba(56,189,248,0.012) 71%, transparent 72%);
|
||||||
|
background-size: 400% 400%, 300% 300%;
|
||||||
|
animation: qmSeaMove 14s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes qmSeaMove {
|
||||||
|
0% { background-position: 0% 0%, 100% 0%; }
|
||||||
|
100% { background-position: 100% 100%, 0% 100%; }
|
||||||
|
}
|
||||||
|
.qm-bubble {
|
||||||
|
position:absolute; border-radius:50%; pointer-events:none; z-index:1;
|
||||||
|
background: rgba(255,255,255,0.045);
|
||||||
|
animation: qmBobble var(--bdur) ease-in-out infinite;
|
||||||
|
animation-delay: var(--bdelay);
|
||||||
|
}
|
||||||
|
@keyframes qmBobble {
|
||||||
|
0%,100%{ transform:translateY(0) scale(1); opacity:0.5; }
|
||||||
|
50% { transform:translateY(-10px) scale(1.1); opacity:0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Arc banner ── */
|
||||||
|
.qm-arc-banner {
|
||||||
|
position:relative; z-index:5;
|
||||||
|
border-radius:22px; padding: 1.1rem 1.25rem; margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.qm-arc-banner::before {
|
||||||
|
content:''; position:absolute; inset:0;
|
||||||
|
background: repeating-linear-gradient(45deg, transparent, transparent 15px, rgba(255,255,255,0.015) 15px, rgba(255,255,255,0.015) 16px);
|
||||||
|
}
|
||||||
|
.qm-arc-banner-bg-emoji {
|
||||||
|
position:absolute; right:0.5rem; top:50%; transform:translateY(-50%);
|
||||||
|
font-size:5rem; opacity:0.09; filter:blur(2px); pointer-events:none; z-index:0;
|
||||||
|
}
|
||||||
|
.qm-arc-banner-name {
|
||||||
|
font-family:'Sorts Mill Goudy',serif; font-size:1.25rem; font-weight:900; color:white;
|
||||||
|
letter-spacing:0.06em; text-shadow: 0 2px 16px rgba(0,0,0,0.6); position:relative; z-index:1;
|
||||||
|
}
|
||||||
|
.qm-arc-banner-sub {
|
||||||
|
font-family:'Nunito Sans',sans-serif; font-size:0.7rem; font-weight:600;
|
||||||
|
color:rgba(255,255,255,0.5); margin-top:0.2rem; position:relative; z-index:1;
|
||||||
|
}
|
||||||
|
.qm-arc-banner-prog {
|
||||||
|
display:flex; align-items:center; gap:0.65rem; margin-top:0.8rem; position:relative; z-index:1;
|
||||||
|
}
|
||||||
|
.qm-arc-banner-track { flex:1; height:5px; border-radius:100px; background:rgba(255,255,255,0.12); overflow:hidden; }
|
||||||
|
.qm-arc-banner-fill {
|
||||||
|
height:100%; border-radius:100px; background:rgba(255,255,255,0.8);
|
||||||
|
box-shadow:0 0 8px rgba(255,255,255,0.5); transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.qm-arc-banner-count {
|
||||||
|
font-family:'Nunito',sans-serif; font-size:0.68rem; font-weight:900;
|
||||||
|
color:rgba(255,255,255,0.65); white-space:nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ MAP SVG CANVAS ══ */
|
||||||
|
.qm-map-svg { display:block; width:100%; overflow:visible; position:relative; z-index:5; }
|
||||||
|
|
||||||
|
/* ── Info card (foreignObject inside SVG) ── */
|
||||||
|
.qm-info-card {
|
||||||
|
background: rgba(255,255,255,0.055); border:1px solid rgba(255,255,255,0.09);
|
||||||
|
border-radius:16px; padding:0.7rem 0.85rem;
|
||||||
|
backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.qm-info-card.is-claimable { border-color:rgba(251,191,36,0.45); background:rgba(251,191,36,0.07); }
|
||||||
|
.qm-info-card.is-active { border-color:rgba(255,255,255,0.14); }
|
||||||
|
.qm-info-card.is-locked { opacity:0.42; }
|
||||||
|
.qm-info-row1 { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:0.4rem; gap:0.4rem; }
|
||||||
|
.qm-info-title { font-family:'Sorts Mill Goudy',serif; font-size:0.78rem; font-weight:700; color:white; line-height:1.25; }
|
||||||
|
.qm-info-loc { font-size:0.52rem; font-weight:800; letter-spacing:0.14em; text-transform:uppercase; color:var(--arc-accent); margin-bottom:0.12rem; }
|
||||||
|
.qm-xp-badge {
|
||||||
|
display:flex; align-items:center; gap:0.18rem; padding:0.18rem 0.45rem;
|
||||||
|
background:rgba(251,191,36,0.13); border:1px solid rgba(251,191,36,0.3);
|
||||||
|
border-radius:100px; flex-shrink:0;
|
||||||
|
}
|
||||||
|
.qm-xp-badge-val { font-size:0.62rem; font-weight:900; color:#fbbf24; }
|
||||||
|
.qm-prog-track { height:5px; background:rgba(255,255,255,0.08); border-radius:100px; overflow:hidden; margin-bottom:0.22rem; }
|
||||||
|
.qm-prog-fill {
|
||||||
|
height:100%; border-radius:100px;
|
||||||
|
background:linear-gradient(90deg, var(--arc-accent), color-mix(in srgb,var(--arc-accent) 65%,white));
|
||||||
|
box-shadow:0 0 8px color-mix(in srgb,var(--arc-accent) 55%,transparent);
|
||||||
|
transition:width 0.7s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
.qm-prog-label { font-family:'Nunito Sans',sans-serif; font-size:0.55rem; font-weight:700; color:rgba(255,255,255,0.38); }
|
||||||
|
.qm-claim-btn {
|
||||||
|
width:100%; margin-top:0.5rem; padding:0.48rem;
|
||||||
|
background:linear-gradient(135deg,#fbbf24,#f59e0b); border:none; border-radius:10px; cursor:pointer;
|
||||||
|
font-family:'Sorts Mill Goudy',serif; font-size:0.72rem; font-weight:700;
|
||||||
|
color:#1a0e00; letter-spacing:0.04em;
|
||||||
|
box-shadow:0 3px 0 #d97706, 0 5px 14px rgba(251,191,36,0.3); transition:all 0.12s ease;
|
||||||
|
}
|
||||||
|
.qm-claim-btn:hover { transform:translateY(-1px); box-shadow:0 5px 0 #d97706; }
|
||||||
|
.qm-claim-btn:active { transform:translateY(1px); box-shadow:0 1px 0 #d97706; }
|
||||||
|
|
||||||
|
/* ══ ARC COMPLETE ══ */
|
||||||
|
.qm-arc-done {
|
||||||
|
position:relative; z-index:5; margin-top:1.5rem; padding:1.25rem; text-align:center;
|
||||||
|
background:linear-gradient(135deg,rgba(251,191,36,0.12),rgba(251,191,36,0.04));
|
||||||
|
border:1px solid rgba(251,191,36,0.3); border-radius:20px;
|
||||||
|
box-shadow:0 0 40px rgba(251,191,36,0.06);
|
||||||
|
}
|
||||||
|
.qm-arc-done-title {
|
||||||
|
font-family:'Sorts Mill Goudy',serif; font-size:1rem; font-weight:900; color:#fbbf24;
|
||||||
|
text-shadow:0 0 20px rgba(251,191,36,0.6); margin-bottom:0.2rem;
|
||||||
|
}
|
||||||
|
.qm-arc-done-sub { font-family:'Nunito Sans',sans-serif; font-size:0.7rem; font-weight:600; color:rgba(251,191,36,0.55); }
|
||||||
|
|
||||||
|
/* ══ FAB ══ */
|
||||||
|
.qm-fab {
|
||||||
|
position:fixed; bottom:calc(1.25rem + 80px + env(safe-area-inset-bottom)); right:1.25rem; z-index:25;
|
||||||
|
width:52px; height:52px; border-radius:50%;
|
||||||
|
background:linear-gradient(135deg,#1a0e45,#3730a3); border:2px solid rgba(251,191,36,0.45);
|
||||||
|
display:flex; align-items:center; justify-content:center; font-size:1.5rem; cursor:pointer;
|
||||||
|
box-shadow:0 6px 24px rgba(0,0,0,0.55), 0 0 0 1px rgba(251,191,36,0.15);
|
||||||
|
animation:qmFabFloat 4s ease-in-out infinite;
|
||||||
|
transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.qm-fab:hover { transform:scale(1.1) rotate(8deg); }
|
||||||
|
.qm-fab:active { transform:scale(0.92); }
|
||||||
|
@keyframes qmFabFloat { 0%,100%{ transform:translateY(0) rotate(-4deg); } 50%{ transform:translateY(-7px) rotate(4deg); } }
|
||||||
|
|
||||||
|
/* ══ NODE ENTRANCE ══ */
|
||||||
|
@keyframes qmIslandIn { from{ opacity:0; transform:scale(0.82) translateY(22px); } to{ opacity:1; transform:scale(1) translateY(0); } }
|
||||||
|
.qm-island-in { animation: qmIslandIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||||
|
const TERRAIN: Record<string, { l: string; m: string; d: string; s: string }> =
|
||||||
|
{
|
||||||
|
east_blue: {
|
||||||
|
l: "#5eead4",
|
||||||
|
m: "#0d9488",
|
||||||
|
d: "#0f766e",
|
||||||
|
s: "rgba(13,148,136,0.55)",
|
||||||
|
},
|
||||||
|
alabasta: {
|
||||||
|
l: "#fcd34d",
|
||||||
|
m: "#d97706",
|
||||||
|
d: "#92400e",
|
||||||
|
s: "rgba(146,64,14,0.65)",
|
||||||
|
},
|
||||||
|
skypiea: {
|
||||||
|
l: "#d8b4fe",
|
||||||
|
m: "#9333ea",
|
||||||
|
d: "#6b21a8",
|
||||||
|
s: "rgba(107,33,168,0.55)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const DECOS: Record<string, [string, string, string]> = {
|
||||||
|
east_blue: ["🌴", "🌿", "🌴"],
|
||||||
|
alabasta: ["🌵", "🏺", "🌵"],
|
||||||
|
skypiea: ["☁️", "✨", "☁️"],
|
||||||
|
};
|
||||||
|
const REQ_ICON: Record<string, string> = {
|
||||||
|
questions: "❓",
|
||||||
|
accuracy: "🎯",
|
||||||
|
streak: "🔥",
|
||||||
|
sessions: "📚",
|
||||||
|
topics: "🗺️",
|
||||||
|
xp: "⚡",
|
||||||
|
leaderboard: "🏆",
|
||||||
|
};
|
||||||
|
const FOAM = Array.from({ length: 22 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
w: 10 + ((i * 17 + 7) % 24),
|
||||||
|
top: `${3 + ((i * 13) % 88)}%`,
|
||||||
|
left: `${(i * 19 + 5) % 96}%`,
|
||||||
|
dur: `${4 + ((i * 7) % 7)}s`,
|
||||||
|
delay: `${(i * 3) % 5}s`,
|
||||||
|
}));
|
||||||
|
const completedCount = (arc: QuestArc) =>
|
||||||
|
arc.nodes.filter((n) => n.status === "completed").length;
|
||||||
|
|
||||||
|
// ─── SVG Island node ──────────────────────────────────────────────────────────
|
||||||
|
const IslandNode = ({
|
||||||
|
node,
|
||||||
|
arcId,
|
||||||
|
accent,
|
||||||
|
index,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
onTap,
|
||||||
|
onClaim,
|
||||||
|
}: {
|
||||||
|
node: QuestNode;
|
||||||
|
arcId: string;
|
||||||
|
accent: string;
|
||||||
|
index: number;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
onTap: (n: QuestNode) => void;
|
||||||
|
onClaim: (n: QuestNode) => void;
|
||||||
|
}) => {
|
||||||
|
const terrain = TERRAIN[arcId] ?? TERRAIN.east_blue;
|
||||||
|
const decos = DECOS[arcId] ?? DECOS.east_blue;
|
||||||
|
|
||||||
|
const isCompleted = node.status === "completed";
|
||||||
|
const isClaimable = node.status === "claimable";
|
||||||
|
const isActive = node.status === "active";
|
||||||
|
const isLocked = node.status === "locked";
|
||||||
|
const pct = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((node.progress / node.requirement.target) * 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hiC = isLocked ? "#4b5563" : isCompleted ? "#6ee7b7" : terrain.l;
|
||||||
|
const midC = isLocked ? "#374151" : isCompleted ? "#10b981" : terrain.m;
|
||||||
|
const loC = isLocked ? "#1f2937" : isCompleted ? "#065f46" : terrain.d;
|
||||||
|
const shdC = isLocked ? "rgba(0,0,0,0.5)" : terrain.s;
|
||||||
|
|
||||||
|
const gradId = `grad-${node.id}`;
|
||||||
|
const clipId = `clip-${node.id}`;
|
||||||
|
const shadowId = `shadow-${node.id}`;
|
||||||
|
const glowId = `glow-${node.id}`;
|
||||||
|
const shapeIdx = index % SHAPES.length;
|
||||||
|
|
||||||
|
const LAND_H = 38;
|
||||||
|
const cardTop = cy + LAND_H + 18;
|
||||||
|
|
||||||
|
const statusCard = isClaimable
|
||||||
|
? "is-claimable"
|
||||||
|
: isActive
|
||||||
|
? "is-active"
|
||||||
|
: isLocked
|
||||||
|
? "is-locked"
|
||||||
|
: "is-completed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
style={{ cursor: isLocked ? "default" : "pointer" }}
|
||||||
|
onClick={() => !isLocked && onTap(node)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={gradId} cx="38%" cy="28%" r="65%">
|
||||||
|
<stop offset="0%" stopColor={hiC} />
|
||||||
|
<stop offset="55%" stopColor={midC} />
|
||||||
|
<stop offset="100%" stopColor={loC} />
|
||||||
|
</radialGradient>
|
||||||
|
<filter id={shadowId} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feDropShadow
|
||||||
|
dx="0"
|
||||||
|
dy="9"
|
||||||
|
stdDeviation="7"
|
||||||
|
floodColor={shdC}
|
||||||
|
floodOpacity="0.8"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="7" result="blur" />
|
||||||
|
<feFlood
|
||||||
|
floodColor={isClaimable ? "#fbbf24" : accent}
|
||||||
|
floodOpacity="0.55"
|
||||||
|
result="col"
|
||||||
|
/>
|
||||||
|
<feComposite in="col" in2="blur" operator="in" result="glow" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="glow" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<clipPath id={clipId}>
|
||||||
|
<g dangerouslySetInnerHTML={{ __html: SHAPES[shapeIdx] }} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Water shimmer halo */}
|
||||||
|
<ellipse
|
||||||
|
cx={cx}
|
||||||
|
cy={cy + LAND_H - 4}
|
||||||
|
rx={isLocked ? 40 : 56}
|
||||||
|
ry={12}
|
||||||
|
fill="rgba(56,189,248,0.22)"
|
||||||
|
style={{ filter: "blur(5px)" }}
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="rx"
|
||||||
|
values={`${isLocked ? 40 : 56};${isLocked ? 46 : 62};${isLocked ? 40 : 56}`}
|
||||||
|
dur="3s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0.6;1;0.6"
|
||||||
|
dur="3s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</ellipse>
|
||||||
|
|
||||||
|
{/* Land shadow blob */}
|
||||||
|
<g
|
||||||
|
transform={`translate(${cx},${cy + 12})`}
|
||||||
|
style={{ filter: "blur(10px)" }}
|
||||||
|
opacity="0.5"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
dangerouslySetInnerHTML={{ __html: SHAPES[shapeIdx] }}
|
||||||
|
style={{ fill: shdC }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Active / claimable glow ring */}
|
||||||
|
{(isActive || isClaimable) && (
|
||||||
|
<g transform={`translate(${cx},${cy}) scale(1.22)`}>
|
||||||
|
<g
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: SHAPES[shapeIdx]
|
||||||
|
.replace(
|
||||||
|
">",
|
||||||
|
` fill="none" stroke="${isClaimable ? "#fbbf24" : accent}" stroke-width="1.8" stroke-dasharray="6 4" opacity="0.6">`,
|
||||||
|
)
|
||||||
|
.replace("<", "<"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Land shape */}
|
||||||
|
<g
|
||||||
|
transform={`translate(${cx},${cy})`}
|
||||||
|
filter={`url(#${isActive || isClaimable ? glowId : shadowId})`}
|
||||||
|
opacity={isLocked ? 0.45 : 1}
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: SHAPES[shapeIdx].replace(">", ` fill="url(#${gradId})">`),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Decorations */}
|
||||||
|
{!isLocked && (
|
||||||
|
<>
|
||||||
|
<text
|
||||||
|
x={cx - 22}
|
||||||
|
y={cy - LAND_H - 6}
|
||||||
|
fontSize="13"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.5))" }}
|
||||||
|
>
|
||||||
|
{decos[0]}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={cx + 22}
|
||||||
|
y={cy - LAND_H - 2}
|
||||||
|
fontSize="15"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.5))" }}
|
||||||
|
>
|
||||||
|
{decos[1]}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pirate flag on active */}
|
||||||
|
{isActive && (
|
||||||
|
<g transform={`translate(${cx - 8},${cy - LAND_H - 26})`}>
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="-20"
|
||||||
|
stroke="#6b4226"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path d="M0,-20 L16,-14 L0,-8Z" fill="#ef4444" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bouncing chest on claimable */}
|
||||||
|
{isClaimable && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy - LAND_H - 8}
|
||||||
|
fontSize="18"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 4px 8px rgba(251,191,36,0.7))" }}
|
||||||
|
>
|
||||||
|
📦
|
||||||
|
<animate
|
||||||
|
attributeName="y"
|
||||||
|
values={`${cy - LAND_H - 8};${cy - LAND_H - 18};${cy - LAND_H - 8}`}
|
||||||
|
dur="1.4s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lock icon */}
|
||||||
|
{isLocked && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy + 6}
|
||||||
|
fontSize="18"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
opacity="0.4"
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quest emoji */}
|
||||||
|
{!isLocked && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy + 6}
|
||||||
|
fontSize="18"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.5))" }}
|
||||||
|
>
|
||||||
|
{node.emoji}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed check */}
|
||||||
|
{isCompleted && (
|
||||||
|
<g transform={`translate(${cx + 40},${cy - LAND_H + 4})`}>
|
||||||
|
<circle
|
||||||
|
r="11"
|
||||||
|
fill="#22c55e"
|
||||||
|
stroke="rgba(255,255,255,0.9)"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
/>
|
||||||
|
<text x="0" y="5" fontSize="12" textAnchor="middle" fill="white">
|
||||||
|
✓
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Island name label */}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy + LAND_H + 10}
|
||||||
|
fontSize="8.5"
|
||||||
|
fontFamily="'Sorts Mill Goudy',serif"
|
||||||
|
fontWeight="700"
|
||||||
|
fill="rgba(255,255,255,0.45)"
|
||||||
|
textAnchor="middle"
|
||||||
|
letterSpacing="0.1em"
|
||||||
|
>
|
||||||
|
{node.islandName?.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Info card via foreignObject */}
|
||||||
|
<foreignObject
|
||||||
|
x={cx - CARD_W / 2}
|
||||||
|
y={cardTop}
|
||||||
|
width={CARD_W}
|
||||||
|
height={CARD_H}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`qm-info-card ${statusCard}`}
|
||||||
|
style={{ ["--arc-accent" as string]: accent }}
|
||||||
|
onClick={() => !isLocked && onTap(node)}
|
||||||
|
>
|
||||||
|
<div className="qm-info-row1">
|
||||||
|
<p className="qm-info-title">{node.title}</p>
|
||||||
|
<div className="qm-xp-badge">
|
||||||
|
<span style={{ fontSize: "0.58rem" }}>⚡</span>
|
||||||
|
<span className="qm-xp-badge-val">+{node.reward.xp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(isActive || isClaimable) && (
|
||||||
|
<>
|
||||||
|
<div className="qm-prog-track">
|
||||||
|
<div className="qm-prog-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="qm-prog-label">
|
||||||
|
{REQ_ICON[node.requirement.type]}
|
||||||
|
{node.progress}/{node.requirement.target}{" "}
|
||||||
|
{node.requirement.label}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isLocked && (
|
||||||
|
<p className="qm-prog-label">
|
||||||
|
🔒 {node.requirement.target} {node.requirement.label} to unlock
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isCompleted && (
|
||||||
|
<p className="qm-prog-label" style={{ color: "#4ade80" }}>
|
||||||
|
✅ Conquered!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isClaimable && (
|
||||||
|
<button
|
||||||
|
className="qm-claim-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClaim(node);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚓ Open Chest
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── SVG Path between two island centres ─────────────────────────────────────
|
||||||
|
const RoutePath = ({
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
done,
|
||||||
|
accent,
|
||||||
|
showShip,
|
||||||
|
}: {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
done: boolean;
|
||||||
|
accent: string;
|
||||||
|
showShip: boolean;
|
||||||
|
}) => {
|
||||||
|
const mx = (x1 + x2) / 2;
|
||||||
|
const my = (y1 + y2) / 2;
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
const perp = 55;
|
||||||
|
const side = x1 < x2 ? 1 : -1;
|
||||||
|
const cpx = mx - (dy / len) * perp * side;
|
||||||
|
const cpy = my + (dx / len) * perp * side;
|
||||||
|
|
||||||
|
const path = `M ${x1} ${y1} Q ${cpx} ${cpy} ${x2} ${y2}`;
|
||||||
|
const shipX = 0.25 * x1 + 0.5 * cpx + 0.25 * x2;
|
||||||
|
const shipY = 0.25 * y1 + 0.5 * cpy + 0.25 * y2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.06)"
|
||||||
|
strokeWidth="18"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={done ? accent : "rgba(255,255,255,0.2)"}
|
||||||
|
strokeWidth={done ? "2.5" : "1.8"}
|
||||||
|
strokeDasharray={done ? "10 6" : "6 8"}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ filter: done ? `drop-shadow(0 0 5px ${accent})` : "none" }}
|
||||||
|
/>
|
||||||
|
{[0.25, 0.5, 0.75].map((t, ti) => {
|
||||||
|
const ex = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * cpx + t * t * x2;
|
||||||
|
const ey = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * cpy + t * t * y2;
|
||||||
|
return (
|
||||||
|
<ellipse
|
||||||
|
key={ti}
|
||||||
|
cx={ex}
|
||||||
|
cy={ey}
|
||||||
|
rx="8"
|
||||||
|
ry="2.5"
|
||||||
|
fill="rgba(255,255,255,0.04)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showShip && (
|
||||||
|
<text
|
||||||
|
x={shipX}
|
||||||
|
y={shipY}
|
||||||
|
fontSize="18"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
style={{ filter: "drop-shadow(0 3px 6px rgba(0,0,0,0.5))" }}
|
||||||
|
>
|
||||||
|
⛵
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="translate"
|
||||||
|
values="0,0;0,-5;0,0"
|
||||||
|
dur="2.5s"
|
||||||
|
additive="sum"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="rotate"
|
||||||
|
values={`-6,${shipX},${shipY};6,${shipX},${shipY};-6,${shipX},${shipY}`}
|
||||||
|
dur="2.5s"
|
||||||
|
additive="sum"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
export const QuestMap = () => {
|
||||||
|
// ── Store — select ONLY stable primitives/actions, never derived functions ──
|
||||||
|
const arcs = useQuestStore((s) => s.arcs);
|
||||||
|
const activeArcId = useQuestStore((s) => s.activeArcId);
|
||||||
|
const setActiveArc = useQuestStore((s) => s.setActiveArc);
|
||||||
|
const claimNode = useQuestStore((s) => s.claimNode);
|
||||||
|
|
||||||
|
// Derived values — computed from arcs outside the selector, never causes loops
|
||||||
|
const summary = getQuestSummary(arcs);
|
||||||
|
|
||||||
|
// ── Local UI state (doesn't need to be global) ──
|
||||||
|
const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);
|
||||||
|
const [claimingNode, setClaimingNode] = useState<QuestNode | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];
|
||||||
|
const done = completedCount(arc);
|
||||||
|
const pct = Math.round((done / arc.nodes.length) * 100);
|
||||||
|
|
||||||
|
const handleClaim = (node: QuestNode) => setClaimingNode(node);
|
||||||
|
const handleChestClose = () => {
|
||||||
|
if (!claimingNode) return;
|
||||||
|
claimNode(arc.id, claimingNode.id); // store handles state update + next unlock
|
||||||
|
setClaimingNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = arc.nodes;
|
||||||
|
const centres = nodes.map((_, i) => ({
|
||||||
|
x: islandCX(i, arc.id),
|
||||||
|
y: islandCY(i),
|
||||||
|
}));
|
||||||
|
const totalSvgH = svgHeight(nodes.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="qm-screen">
|
||||||
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="qm-header">
|
||||||
|
<p className="qm-page-title">🏴☠️ Treasure Quests</p>
|
||||||
|
<p className="qm-page-sub">Chart your course across the Grand Line</p>
|
||||||
|
<div className="qm-stats-strip">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
e: "⚓",
|
||||||
|
v: `${summary.completedNodes}/${summary.totalNodes}`,
|
||||||
|
l: "Quests",
|
||||||
|
},
|
||||||
|
{ e: "⚡", v: `${summary.earnedXP} XP`, l: "Earned" },
|
||||||
|
{ e: "📦", v: `${summary.claimableNodes}`, l: "Chests" },
|
||||||
|
{
|
||||||
|
e: "🏝️",
|
||||||
|
v: `${summary.arcsCompleted}/${summary.totalArcs}`,
|
||||||
|
l: "Arcs",
|
||||||
|
},
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.l} className="qm-stat-chip">
|
||||||
|
<span style={{ fontSize: "0.78rem" }}>{s.e}</span>
|
||||||
|
<span className="qm-stat-val">{s.v}</span>
|
||||||
|
<span className="qm-stat-label">{s.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="qm-arc-tabs">
|
||||||
|
{arcs.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`}
|
||||||
|
style={{ "--arc-accent": a.accentColor } as React.CSSProperties}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveArc(a.id);
|
||||||
|
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.emoji} {a.name}
|
||||||
|
{a.nodes.some((n) => n.status === "claimable") && (
|
||||||
|
<span className="qm-tab-dot" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sea */}
|
||||||
|
<div className="qm-sea-scroll" ref={scrollRef}>
|
||||||
|
<div className="qm-sea">
|
||||||
|
<div className="qm-sea-shimmer" />
|
||||||
|
{FOAM.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="qm-bubble"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: b.w,
|
||||||
|
height: b.w,
|
||||||
|
top: b.top,
|
||||||
|
left: b.left,
|
||||||
|
"--bdur": b.dur,
|
||||||
|
"--bdelay": b.delay,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Arc banner */}
|
||||||
|
<div
|
||||||
|
className="qm-arc-banner"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg,${arc.bgFrom}dd,${arc.bgTo}ee)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="qm-arc-banner-bg-emoji">{arc.emoji}</div>
|
||||||
|
<p className="qm-arc-banner-name">{arc.name}</p>
|
||||||
|
<p className="qm-arc-banner-sub">{arc.subtitle}</p>
|
||||||
|
<div className="qm-arc-banner-prog">
|
||||||
|
<div className="qm-arc-banner-track">
|
||||||
|
<div
|
||||||
|
className="qm-arc-banner-fill"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="qm-arc-banner-count">
|
||||||
|
{done}/{arc.nodes.length} islands
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Single SVG canvas for the whole map ── */}
|
||||||
|
<svg
|
||||||
|
className="qm-map-svg"
|
||||||
|
viewBox={`0 0 ${VW} ${totalSvgH}`}
|
||||||
|
height={totalSvgH}
|
||||||
|
preserveAspectRatio="xMidYMin meet"
|
||||||
|
>
|
||||||
|
{/* Routes drawn FIRST (behind islands) */}
|
||||||
|
{nodes.map((node, i) => {
|
||||||
|
if (i >= nodes.length - 1) return null;
|
||||||
|
const c1 = centres[i];
|
||||||
|
const c2 = centres[i + 1];
|
||||||
|
const ship =
|
||||||
|
node.status === "completed" &&
|
||||||
|
nodes[i + 1]?.status === "active";
|
||||||
|
return (
|
||||||
|
<RoutePath
|
||||||
|
key={`route-${i}`}
|
||||||
|
x1={c1.x}
|
||||||
|
y1={c1.y}
|
||||||
|
x2={c2.x}
|
||||||
|
y2={c2.y}
|
||||||
|
done={node.status === "completed"}
|
||||||
|
accent={arc.accentColor}
|
||||||
|
showShip={ship}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Islands drawn on top */}
|
||||||
|
{nodes.map((node, i) => (
|
||||||
|
<IslandNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
arcId={arc.id}
|
||||||
|
accent={arc.accentColor}
|
||||||
|
index={i}
|
||||||
|
cx={centres[i].x}
|
||||||
|
cy={centres[i].y}
|
||||||
|
onTap={setSelectedNode}
|
||||||
|
onClaim={handleClaim}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Arc complete seal */}
|
||||||
|
{done === nodes.length && (
|
||||||
|
<g transform={`translate(${VW / 2},${totalSvgH - 60})`}>
|
||||||
|
<circle
|
||||||
|
r="42"
|
||||||
|
fill="rgba(251,191,36,0.12)"
|
||||||
|
stroke="rgba(251,191,36,0.5)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeDasharray="8 4"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
r="34"
|
||||||
|
fill="rgba(255,248,200,0.9)"
|
||||||
|
stroke="rgba(180,120,20,0.4)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="0"
|
||||||
|
y="-8"
|
||||||
|
fontFamily="'Cinzel',serif"
|
||||||
|
fontSize="8"
|
||||||
|
fontWeight="900"
|
||||||
|
fill="#92400e"
|
||||||
|
textAnchor="middle"
|
||||||
|
letterSpacing="0.12em"
|
||||||
|
>
|
||||||
|
ARC
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x="0"
|
||||||
|
y="5"
|
||||||
|
fontFamily="'Cinzel',serif"
|
||||||
|
fontSize="8"
|
||||||
|
fontWeight="900"
|
||||||
|
fill="#92400e"
|
||||||
|
textAnchor="middle"
|
||||||
|
letterSpacing="0.12em"
|
||||||
|
>
|
||||||
|
COMPLETE
|
||||||
|
</text>
|
||||||
|
<text x="0" y="19" fontSize="13" textAnchor="middle">
|
||||||
|
⚓
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="qm-fab"
|
||||||
|
onClick={() =>
|
||||||
|
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
🏴☠️
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedNode && (
|
||||||
|
<QuestNodeModal
|
||||||
|
node={selectedNode}
|
||||||
|
arcAccent={arc.accentColor}
|
||||||
|
arcDark={arc.accentDark}
|
||||||
|
arcId={arc.id}
|
||||||
|
nodeIndex={arc.nodes.findIndex((n) => n.id === selectedNode.id)}
|
||||||
|
onClose={() => setSelectedNode(null)}
|
||||||
|
onClaim={() => {
|
||||||
|
setSelectedNode(null);
|
||||||
|
handleClaim(selectedNode);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{claimingNode && (
|
||||||
|
<ChestOpenModal node={claimingNode} onClose={handleChestClose} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { Outlet, NavLink } from "react-router-dom";
|
import { Outlet, NavLink, useLocation } from "react-router-dom";
|
||||||
import { Home, BookOpen, Award, User, Video } from "lucide-react";
|
import { Home, BookOpen, Award, User, Video, Map } from "lucide-react";
|
||||||
import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar";
|
||||||
import { AppSidebar } from "../../components/AppSidebar";
|
import { AppSidebar } from "../../components/AppSidebar";
|
||||||
|
|
||||||
@ -18,6 +18,13 @@ const NAV_ITEMS = [
|
|||||||
color: "#a855f7",
|
color: "#a855f7",
|
||||||
bg: "rgba(168,85,247,0.12)",
|
bg: "rgba(168,85,247,0.12)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/student/quests",
|
||||||
|
icon: Map,
|
||||||
|
label: "Quests",
|
||||||
|
color: "#587ffc",
|
||||||
|
bg: "rgba(53,75,150,0.12)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/student/lessons",
|
to: "/student/lessons",
|
||||||
icon: Video,
|
icon: Video,
|
||||||
@ -41,19 +48,26 @@ const NAV_ITEMS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const STYLES = `
|
// ── Quest dock overrides: dark navy pirate theme ──────────────────────────────
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap');
|
// Active color on quests page gets the gold treatment instead of the tab color.
|
||||||
|
const QUEST_NAV_ITEMS = NAV_ITEMS.map((item) =>
|
||||||
|
item.to === "/student/quests"
|
||||||
|
? { ...item, color: "#fbbf24", bg: "rgba(251,191,36,0.15)" }
|
||||||
|
: { ...item, color: "#94a3b8", bg: "rgba(255,255,255,0.08)" },
|
||||||
|
);
|
||||||
|
|
||||||
/* ── The floating island dock ── */
|
const STYLES = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Cinzel:wght@700&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy:ital@0;1&display=swap');
|
||||||
|
|
||||||
|
/* ══ DEFAULT dock (cream frosted glass) ══ */
|
||||||
.sl-dock-wrap {
|
.sl-dock-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(1.25rem + env(safe-area-inset-bottom));
|
bottom: calc(1.25rem + env(safe-area-inset-bottom));
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
background: rgba(255,251,244,0.72);
|
||||||
/* Frosted pill */
|
|
||||||
background: rgba(255, 251, 244, 0.72);
|
|
||||||
backdrop-filter: blur(24px) saturate(180%);
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||||
border: 1.5px solid rgba(255,255,255,0.7);
|
border: 1.5px solid rgba(255,255,255,0.7);
|
||||||
@ -62,11 +76,42 @@ const STYLES = `
|
|||||||
0 8px 32px rgba(0,0,0,0.12),
|
0 8px 32px rgba(0,0,0,0.12),
|
||||||
0 2px 8px rgba(0,0,0,0.06),
|
0 2px 8px rgba(0,0,0,0.06),
|
||||||
inset 0 1px 0 rgba(255,255,255,0.8);
|
inset 0 1px 0 rgba(255,255,255,0.8);
|
||||||
|
|
||||||
padding: 0.45rem 0.5rem;
|
padding: 0.45rem 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
|
transition:
|
||||||
|
background 0.4s ease,
|
||||||
|
border-color 0.4s ease,
|
||||||
|
box-shadow 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ QUEST dock (dark navy pirate) ══ */
|
||||||
|
.sl-dock-wrap.quest-mode {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(251,191,36,0.05) 30%,
|
||||||
|
rgba(251,191,36,0.1) 50%,
|
||||||
|
rgba(251,191,36,0.15) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: slGoldSweep 3s linear infinite;
|
||||||
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
border: 1.5px solid rgba(251,191,36,0.28);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0,0,0,0.55),
|
||||||
|
0 2px 8px rgba(0,0,0,0.35),
|
||||||
|
0 0 0 1px rgba(251,191,36,0.08),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes slGoldSweep {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Each nav item ── */
|
/* ── Each nav item ── */
|
||||||
@ -87,6 +132,7 @@ const STYLES = `
|
|||||||
background 0.25s ease;
|
background 0.25s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.sl-dock-item:active { transform: scale(0.91); }
|
.sl-dock-item:active { transform: scale(0.91); }
|
||||||
.sl-dock-item.active {
|
.sl-dock-item.active {
|
||||||
@ -102,11 +148,14 @@ const STYLES = `
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
transition: background 0.25s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
|
transition: background 0.25s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
}
|
}
|
||||||
.sl-dock-item.active .sl-dock-icon {
|
.sl-dock-item.active .sl-dock-icon { transform: scale(1.1); }
|
||||||
transform: scale(1.1);
|
|
||||||
|
/* In quest mode, active quest icon gets a gold glow */
|
||||||
|
.quest-mode .sl-dock-item.active .sl-dock-icon {
|
||||||
|
box-shadow: 0 0 12px rgba(251,191,36,0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Label (only visible when active) ── */
|
/* ── Label ── */
|
||||||
.sl-dock-label {
|
.sl-dock-label {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: 'Nunito', sans-serif;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@ -124,9 +173,32 @@ const STYLES = `
|
|||||||
max-width: 80px;
|
max-width: 80px;
|
||||||
opacity: 1;
|
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() {
|
export function StudentLayout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isQuestPage = location.pathname.startsWith("/student/quests");
|
||||||
|
|
||||||
|
// Pick the right nav item config based on page
|
||||||
|
const items = isQuestPage ? QUEST_NAV_ITEMS : NAV_ITEMS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<style>{STYLES}</style>
|
<style>{STYLES}</style>
|
||||||
@ -136,15 +208,16 @@ export function StudentLayout() {
|
|||||||
|
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<SidebarTrigger className="hidden md:block" />
|
<SidebarTrigger className="hidden md:block" />
|
||||||
{/* Extra bottom padding so content clears the floating dock */}
|
|
||||||
<main className="flex-1 md:pb-0">
|
<main className="flex-1 md:pb-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Floating island dock (mobile only) ── */}
|
{/* ── Floating dock (mobile only) ── */}
|
||||||
<nav className="sl-dock-wrap md:hidden">
|
<nav
|
||||||
{NAV_ITEMS.map((item) => (
|
className={`sl-dock-wrap md:hidden${isQuestPage ? " quest-mode" : ""}`}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
@ -161,7 +234,13 @@ export function StudentLayout() {
|
|||||||
<item.icon
|
<item.icon
|
||||||
size={18}
|
size={18}
|
||||||
strokeWidth={isActive ? 2.5 : 1.75}
|
strokeWidth={isActive ? 2.5 : 1.75}
|
||||||
color={isActive ? item.color : "#94a3b8"}
|
color={
|
||||||
|
isActive
|
||||||
|
? item.color
|
||||||
|
: isQuestPage
|
||||||
|
? "rgba(255,255,255,0.4)"
|
||||||
|
: "#94a3b8"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="sl-dock-label" style={{ color: item.color }}>
|
<span className="sl-dock-label" style={{ color: item.color }}>
|
||||||
|
|||||||
165
src/stores/useQuestStore.ts
Normal file
165
src/stores/useQuestStore.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
import type { QuestArc, QuestNode, NodeStatus } from "../types/quest";
|
||||||
|
import { CREW_RANKS } from "../types/quest";
|
||||||
|
import { QUEST_ARCS } from "../data/questData";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CrewRank {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
emoji: string;
|
||||||
|
xpRequired: number;
|
||||||
|
progressToNext: number; // 0–1 toward next rank
|
||||||
|
next: { label: string; xpRequired: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestSummary {
|
||||||
|
totalNodes: number;
|
||||||
|
completedNodes: number;
|
||||||
|
activeNodes: number;
|
||||||
|
claimableNodes: number;
|
||||||
|
lockedNodes: number;
|
||||||
|
totalXP: number;
|
||||||
|
earnedXP: number;
|
||||||
|
arcsCompleted: number;
|
||||||
|
totalArcs: number;
|
||||||
|
earnedTitles: string[];
|
||||||
|
crewRank: CrewRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store — ONLY raw state + actions, never derived values ───────────────────
|
||||||
|
// Storing functions that return new objects/arrays in Zustand causes infinite
|
||||||
|
// re-render loops because Zustand uses Object.is to detect changes.
|
||||||
|
// All derived values live below as plain helper functions instead.
|
||||||
|
|
||||||
|
interface QuestStore {
|
||||||
|
arcs: QuestArc[];
|
||||||
|
activeArcId: string;
|
||||||
|
setActiveArc: (arcId: string) => void;
|
||||||
|
claimNode: (arcId: string, nodeId: string) => void;
|
||||||
|
syncFromAPI: (arcs: QuestArc[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useQuestStore = create<QuestStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
arcs: QUEST_ARCS,
|
||||||
|
activeArcId: QUEST_ARCS[0].id,
|
||||||
|
|
||||||
|
setActiveArc: (arcId) => set({ activeArcId: arcId }),
|
||||||
|
|
||||||
|
claimNode: (arcId, nodeId) =>
|
||||||
|
set((state) => ({
|
||||||
|
arcs: state.arcs.map((arc) => {
|
||||||
|
if (arc.id !== arcId) return arc;
|
||||||
|
const nodeIdx = arc.nodes.findIndex((n) => n.id === nodeId);
|
||||||
|
if (nodeIdx === -1) return arc;
|
||||||
|
return {
|
||||||
|
...arc,
|
||||||
|
nodes: arc.nodes.map((n, i) => {
|
||||||
|
if (n.id === nodeId)
|
||||||
|
return { ...n, status: "completed" as NodeStatus };
|
||||||
|
if (i === nodeIdx + 1 && n.status === "locked")
|
||||||
|
return { ...n, status: "active" as NodeStatus };
|
||||||
|
return n;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
syncFromAPI: (arcs) => set({ arcs }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "quest-store",
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
arcs: state.arcs,
|
||||||
|
activeArcId: state.activeArcId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Standalone helper functions ──────────────────────────────────────────────
|
||||||
|
// Call these in your components AFTER selecting arcs from the store.
|
||||||
|
// Because they take arcs as an argument (not selected from the store),
|
||||||
|
// they never cause re-render loops.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const arcs = useQuestStore(s => s.arcs);
|
||||||
|
// const summary = getQuestSummary(arcs);
|
||||||
|
// const rank = getCrewRank(arcs);
|
||||||
|
|
||||||
|
export function getEarnedXP(arcs: QuestArc[]): number {
|
||||||
|
return arcs
|
||||||
|
.flatMap((a) => a.nodes)
|
||||||
|
.filter((n) => n.status === "completed")
|
||||||
|
.reduce((sum, n) => sum + n.reward.xp, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCrewRank(arcs: QuestArc[]): CrewRank {
|
||||||
|
const xp = getEarnedXP(arcs);
|
||||||
|
const ladder = [...CREW_RANKS];
|
||||||
|
let idx = 0;
|
||||||
|
for (let i = ladder.length - 1; i >= 0; i--) {
|
||||||
|
if (xp >= ladder[i].xpRequired) {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = ladder[idx];
|
||||||
|
const nextRank = ladder[idx + 1] ?? null;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
progressToNext: nextRank
|
||||||
|
? Math.min(
|
||||||
|
1,
|
||||||
|
(xp - current.xpRequired) /
|
||||||
|
(nextRank.xpRequired - current.xpRequired),
|
||||||
|
)
|
||||||
|
: 1,
|
||||||
|
next: nextRank
|
||||||
|
? { label: nextRank.label, xpRequired: nextRank.xpRequired }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestSummary(arcs: QuestArc[]): QuestSummary {
|
||||||
|
const allNodes = arcs.flatMap((a) => a.nodes);
|
||||||
|
const earnedXP = getEarnedXP(arcs);
|
||||||
|
return {
|
||||||
|
totalNodes: allNodes.length,
|
||||||
|
completedNodes: allNodes.filter((n) => n.status === "completed").length,
|
||||||
|
activeNodes: allNodes.filter((n) => n.status === "active").length,
|
||||||
|
claimableNodes: allNodes.filter((n) => n.status === "claimable").length,
|
||||||
|
lockedNodes: allNodes.filter((n) => n.status === "locked").length,
|
||||||
|
totalXP: allNodes.reduce((s, n) => s + n.reward.xp, 0),
|
||||||
|
earnedXP,
|
||||||
|
arcsCompleted: arcs.filter((a) =>
|
||||||
|
a.nodes.every((n) => n.status === "completed"),
|
||||||
|
).length,
|
||||||
|
totalArcs: arcs.length,
|
||||||
|
earnedTitles: allNodes
|
||||||
|
.filter((n) => n.status === "completed" && n.reward.title)
|
||||||
|
.map((n) => n.reward.title!),
|
||||||
|
crewRank: getCrewRank(arcs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClaimableCount(arcs: QuestArc[]): number {
|
||||||
|
return arcs.flatMap((a) => a.nodes).filter((n) => n.status === "claimable")
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNode(
|
||||||
|
arcs: QuestArc[],
|
||||||
|
nodeId: string,
|
||||||
|
): QuestNode | undefined {
|
||||||
|
return arcs.flatMap((a) => a.nodes).find((n) => n.id === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveArc(arcs: QuestArc[], activeArcId: string): QuestArc {
|
||||||
|
return arcs.find((a) => a.id === activeArcId) ?? arcs[0];
|
||||||
|
}
|
||||||
60
src/types/quest.ts
Normal file
60
src/types/quest.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// ─── Quest System Types ───────────────────────────────────────────────────────
|
||||||
|
// Swap dummy data for API responses later — shape stays the same.
|
||||||
|
|
||||||
|
export type RequirementType =
|
||||||
|
| "questions"
|
||||||
|
| "accuracy"
|
||||||
|
| "streak"
|
||||||
|
| "sessions"
|
||||||
|
| "topics"
|
||||||
|
| "xp"
|
||||||
|
| "leaderboard";
|
||||||
|
|
||||||
|
export type NodeStatus = "locked" | "active" | "claimable" | "completed";
|
||||||
|
|
||||||
|
export type RewardItem = "streak_shield" | "xp_boost" | "title";
|
||||||
|
|
||||||
|
export interface QuestReward {
|
||||||
|
xp: number;
|
||||||
|
title?: string; // crew rank title, e.g. "Navigator"
|
||||||
|
item?: RewardItem;
|
||||||
|
itemLabel?: string; // human-readable, e.g. "Streak Shield ×1"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
flavourText: string;
|
||||||
|
islandName: string; // displayed under the node
|
||||||
|
emoji: string; // island character emoji
|
||||||
|
requirement: {
|
||||||
|
type: RequirementType;
|
||||||
|
target: number;
|
||||||
|
label: string; // e.g. "questions answered"
|
||||||
|
};
|
||||||
|
progress: number; // 0 → requirement.target (API will fill this)
|
||||||
|
status: NodeStatus;
|
||||||
|
reward: QuestReward;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestArc {
|
||||||
|
id: string;
|
||||||
|
name: string; // "East Blue", "Alabasta", "Skypiea"
|
||||||
|
subtitle: string; // short flavour line
|
||||||
|
emoji: string;
|
||||||
|
accentColor: string; // CSS color for this arc's theme
|
||||||
|
accentDark: string;
|
||||||
|
bgFrom: string; // gradient start for arc header
|
||||||
|
bgTo: string;
|
||||||
|
nodes: QuestNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Crew Rank ladder (shown on profile / leaderboard) ───────────────────────
|
||||||
|
export const CREW_RANKS = [
|
||||||
|
{ id: "cabin_boy", label: "Cabin Boy", emoji: "⚓", xpRequired: 0 },
|
||||||
|
{ id: "navigator", label: "Navigator", emoji: "🗺️", xpRequired: 500 },
|
||||||
|
{ id: "first_mate", label: "First Mate", emoji: "⚔️", xpRequired: 1500 },
|
||||||
|
{ id: "warlord", label: "Warlord", emoji: "🔱", xpRequired: 3000 },
|
||||||
|
{ id: "emperor", label: "Emperor", emoji: "👑", xpRequired: 6000 },
|
||||||
|
{ id: "pirate_king", label: "Pirate King", emoji: "🏴☠️", xpRequired: 10000 },
|
||||||
|
] as const;
|
||||||
Reference in New Issue
Block a user