import { useEffect, useState } from "react"; import { X, Lock } from "lucide-react"; import type { QuestNode, QuestArc } from "../types/quest"; // Re-use the same theme generator as QuestMap so island colours are consistent import { generateArcTheme } from "../pages/student/QuestMap"; // ─── Requirement helpers (mirrors QuestMap / InfoHeader) ────────────────────── const REQ_LABEL: Record = { questions: "questions answered", accuracy: "% accuracy", streak: "day streak", sessions: "sessions", topics: "topics covered", xp: "XP earned", leaderboard: "leaderboard rank", }; const reqIcon = (type: string): string => ({ questions: "❓", accuracy: "🎯", streak: "🔥", sessions: "📚", topics: "🗺️", xp: "⚡", leaderboard: "🏆", })[type] ?? "⭐"; // ─── Styles ─────────────────────────────────────────────────────────────────── const STYLES = ` @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); /* ══ OVERLAY ══ */ .qnm-overlay { position: fixed; inset: 0; z-index: 40; background: rgba(4,8,20,0.72); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); display: flex; align-items: flex-end; justify-content: center; animation: qnmFade 0.22s ease both; } @keyframes qnmFade { from{opacity:0} to{opacity:1} } /* ══ SHEET ══ */ .qnm-sheet { width: 100%; max-width: 520px; background: #06101f; border-radius: 28px 28px 0 0; border-top: 1.5px solid rgba(251,191,36,0.2); box-shadow: 0 -12px 60px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.06); overflow: hidden; display: flex; flex-direction: column; max-height: 92vh; animation: qnmUp 0.38s cubic-bezier(0.34,1.56,0.64,1) both; } @keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} } .qnm-handle-row { display:flex; justify-content:center; padding:0.8rem 0 0.3rem; flex-shrink:0; } .qnm-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); } .qnm-close { position:absolute; top:0.9rem; right:1.1rem; z-index:10; width:30px; height:30px; border-radius:50%; border:1.5px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06); display:flex; align-items:center; justify-content:center; cursor:pointer; transition: all 0.15s ease; } .qnm-close:hover { border-color:rgba(251,191,36,0.5); background:rgba(251,191,36,0.1); } /* ══ 3D ISLAND STAGE ══ */ .qnm-stage { position: relative; flex-shrink: 0; height: 200px; overflow: hidden; background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%); } .qnm-sea { position:absolute; bottom:0; left:0; right:0; height:52px; background: var(--sea-col); overflow:hidden; } .qnm-wave { position:absolute; bottom:0; left:-100%; width:300%; height:30px; border-radius:50% 50% 0 0; background: rgba(255,255,255,0.07); animation: qnmWave var(--wdur,5s) ease-in-out infinite; } .qnm-wave:nth-child(2){ animation-delay:-2s; opacity:0.5; } .qnm-wave:nth-child(3){ animation-delay:-4s; opacity:0.3; height:20px; } @keyframes qnmWave { 0% { transform: translateX(0) scaleY(1); } 50% { transform: translateX(15%) scaleY(1.08);} 100%{ transform: translateX(0) scaleY(1); } } .qnm-cloud { position:absolute; border-radius:50px; background: rgba(255,255,255,0.18); filter: blur(4px); animation: qnmDrift var(--cdur,18s) linear infinite; } @keyframes qnmDrift { 0% { transform: translateX(-120px); opacity:0; } 10% { opacity:1; } 90% { opacity:1; } 100%{ transform: translateX(calc(100vw + 120px)); opacity:0; } } .qnm-island-3d-wrap { position: absolute; left: 50%; bottom: 40px; transform: translateX(-50%); perspective: 420px; width: 220px; height: 140px; } .qnm-island-3d { width: 100%; height: 100%; transform-style: preserve-3d; animation: qnmIslandSpin 18s linear infinite; position: relative; } @keyframes qnmIslandSpin { 0% { transform: rotateX(22deg) rotateY(0deg); } 100% { transform: rotateX(22deg) rotateY(360deg); } } .qnm-il { position: absolute; left: 50%; bottom: 0; transform-origin: bottom center; border-radius: 50%; transform-style: preserve-3d; } .qnm-il-water { width: 200px; height: 44px; margin-left: -100px; background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col)); border-radius: 50%; transform: translateZ(-4px); box-shadow: 0 0 40px var(--sea-col); animation: qnmWaterShimmer 3s ease-in-out infinite; } @keyframes qnmWaterShimmer { 0%,100%{ opacity:1; } 50%{ opacity:0.82; } } .qnm-ripple { position:absolute; left:50%; top:50%; border-radius:50%; border:1.5px solid rgba(255,255,255,0.25); animation: qnmRipple 2.8s ease-out infinite; } .qnm-ripple:nth-child(2){ animation-delay:-1.4s; } @keyframes qnmRipple { 0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; } 100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; } } .qnm-il-ground { width: 160px; height: 36px; margin-left: -80px; background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo)); border-radius: 50%; transform: translateZ(14px); box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25); } .qnm-il-side { width: 158px; height: 22px; margin-left: -79px; bottom: -12px; background: linear-gradient(180deg, var(--terr-lo), rgba(0,0,0,0.6)); clip-path: ellipse(79px 100% at 50% 0%); transform: translateZ(8px) rotateX(-8deg); } .qnm-il-peak { width: 80px; height: 60px; margin-left: -40px; bottom: 26px; background: radial-gradient(ellipse at 42% 25%, var(--peak-hi), var(--peak-mid) 60%, var(--peak-lo)); clip-path: var(--peak-shape, polygon(50% 0%, 80% 55%, 100% 100%, 0% 100%, 20% 55%)); transform: translateZ(26px); filter: drop-shadow(0 6px 12px rgba(0,0,0,0.5)); animation: qnmPeakBob 4s ease-in-out infinite; } @keyframes qnmPeakBob { 0%,100%{ transform: translateZ(26px) translateY(0); } 50% { transform: translateZ(26px) translateY(-4px); } } .qnm-il-deco { position: absolute; bottom: 56px; left: 50%; transform: translateZ(42px); animation: qnmDecoFloat 3s ease-in-out infinite; } @keyframes qnmDecoFloat { 0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); } 50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); } } .qnm-deco-emoji { font-size:1.4rem; filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5)); } .qnm-il-flag { position:absolute; bottom:56px; left:50%; transform: translateZ(50px) translateX(12px); } .qnm-flag-pole { width:2px; height:26px; background:#7c4a1e; border-radius:2px; } .qnm-flag-cloth { position:absolute; top:2px; left:2px; width:16px; height:11px; background:#ef4444; clip-path:polygon(0%0%,100%25%,0%100%); animation: qnmFlagWave 1.2s ease-in-out infinite; transform-origin:left center; } @keyframes qnmFlagWave { 0%,100%{ transform:skewY(0deg); } 50%{ transform:skewY(-10deg); } } .qnm-star { position:absolute; font-size:1rem; animation: qnmStarPop var(--sdur,2s) ease-in-out infinite; animation-delay: var(--sdel,0s); } @keyframes qnmStarPop { 0%,100%{ transform:scale(1) translateY(0); opacity:0.8; } 50% { transform:scale(1.4) translateY(-8px); opacity:1; } } /* ══ CONTENT BELOW THE STAGE ══ */ .qnm-body { flex:1; overflow-y:auto; scrollbar-width:none; display:flex; flex-direction:column; gap:0.85rem; padding:1.1rem 1.25rem 0.5rem; } .qnm-body::-webkit-scrollbar { display:none; } .qnm-title-block { position:relative; } .qnm-arc-tag { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.58rem; font-weight:800; letter-spacing:0.14em; text-transform:uppercase; color:var(--ac); background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.08); border-radius:100px; padding:0.18rem 0.6rem; margin-bottom:0.45rem; } .qnm-quest-title { font-family:'Cinzel',serif; font-size:1.22rem; font-weight:700; color:white; letter-spacing:0.02em; line-height:1.2; margin-bottom:0.18rem; } .qnm-island-name { font-family:'Nunito Sans',sans-serif; font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38); } .qnm-flavour { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07); border-left:3px solid var(--ac); border-radius:0 14px 14px 0; padding:0.8rem 1rem; } .qnm-flavour-text { font-family:'Sorts Mill Goudy',serif; font-size:0.82rem; color:rgba(255,255,255,0.55); font-style:italic; line-height:1.6; } .qnm-obj-card { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:18px; padding:0.9rem 1rem; } .qnm-obj-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.65rem; } .qnm-obj-label { font-size:0.58rem; font-weight:800; letter-spacing:0.14em; text-transform:uppercase; color:rgba(255,255,255,0.3); } .qnm-obj-pct { font-family:'Nunito',sans-serif; font-size:0.78rem; font-weight:900; color:var(--ac); } .qnm-obj-row { display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem; } .qnm-obj-icon { width:38px; height:38px; border-radius:12px; flex-shrink:0; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08); display:flex; align-items:center; justify-content:center; font-size:1.1rem; } .qnm-obj-text { font-family:'Nunito',sans-serif; font-size:0.88rem; font-weight:900; color:white; } .qnm-obj-sub { font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem; } .qnm-bar-track { height:9px; background:rgba(255,255,255,0.07); border-radius:100px; overflow:hidden; margin-bottom:0.3rem; } .qnm-bar-fill { height:100%; border-radius:100px; background:linear-gradient(90deg, var(--ac), color-mix(in srgb, var(--ac) 65%, white)); box-shadow:0 0 10px color-mix(in srgb, var(--ac) 55%, transparent); transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1); } .qnm-bar-nums { display:flex; justify-content:space-between; font-family:'Nunito',sans-serif; font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28); } .qnm-bar-nums span:first-child { color:var(--ac); } .qnm-howto-label { font-size:0.58rem; font-weight:800; letter-spacing:0.14em; text-transform:uppercase; color:rgba(255,255,255,0.3); margin-bottom:0.55rem; margin-top:0.3rem; } .qnm-howto-badges { display:flex; flex-wrap:wrap; gap:0.4rem; } .qnm-howto-badge { display:flex; align-items:center; gap:0.3rem; padding:0.38rem 0.75rem; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1); border-radius:100px; font-family:'Nunito',sans-serif; font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7); transition: all 0.15s ease; animation: qnmBadgeIn 0.35s cubic-bezier(0.34,1.56,0.64,1) both; animation-delay: var(--bdel, 0s); } @keyframes qnmBadgeIn { from{ opacity:0; transform:scale(0.8) translateY(6px); } to { opacity:1; transform:scale(1) translateY(0); } } .qnm-howto-badge:hover { background:rgba(255,255,255,0.1); border-color:rgba(255,255,255,0.2); color:white; transform:translateY(-1px); } .qnm-howto-badge.hi { background:color-mix(in srgb, var(--ac) 18%, transparent); border-color:color-mix(in srgb, var(--ac) 45%, transparent); color:var(--ac); } .qnm-locked-banner { display:flex; align-items:center; gap:0.7rem; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07); border-radius:16px; padding:0.9rem 1rem; } .qnm-locked-icon { width:38px; height:38px; border-radius:12px; flex-shrink:0; background:rgba(255,255,255,0.05); display:flex; align-items:center; justify-content:center; } .qnm-locked-text { font-family:'Nunito',sans-serif; font-size:0.82rem; font-weight:800; color:rgba(255,255,255,0.4); } .qnm-locked-sub { font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem; } .qnm-reward-card { background:rgba(251,191,36,0.07); border:1px solid rgba(251,191,36,0.22); border-radius:18px; padding:0.9rem 1rem; } .qnm-reward-label { font-size:0.58rem; font-weight:800; letter-spacing:0.14em; text-transform:uppercase; color:rgba(251,191,36,0.6); margin-bottom:0.6rem; } .qnm-reward-row { display:flex; flex-wrap:wrap; gap:0.4rem; } .qnm-reward-pill { display:flex; align-items:center; gap:0.28rem; padding:0.35rem 0.8rem; background:rgba(251,191,36,0.1); border:1.5px solid rgba(251,191,36,0.3); border-radius:100px; font-family:'Nunito',sans-serif; font-size:0.75rem; font-weight:900; color:#fbbf24; box-shadow:0 2px 8px rgba(251,191,36,0.1); } /* ══ FOOTER CTA ══ */ .qnm-footer { padding:1rem 1.25rem calc(1rem + env(safe-area-inset-bottom)); flex-shrink:0; border-top:1px solid rgba(255,255,255,0.07); background:#06101f; } .qnm-claim-btn { width:100%; padding:0.95rem; background:linear-gradient(135deg,#fbbf24,#f59e0b); border:none; border-radius:16px; cursor:pointer; font-family:'Cinzel',serif; font-size:1rem; font-weight:700; color:#1a0800; letter-spacing:0.04em; box-shadow:0 5px 0 #d97706, 0 8px 24px rgba(251,191,36,0.35); transition:all 0.12s ease; } .qnm-claim-btn:hover { transform:translateY(-2px); box-shadow:0 7px 0 #d97706; } .qnm-claim-btn:active { transform:translateY(2px); box-shadow:0 3px 0 #d97706; } .qnm-note { text-align:center; font-family:'Nunito Sans',sans-serif; font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.28); padding:0.4rem 0; } `; // ─── How-to badges ──────────────────────────────────────────────────────────── interface Badge { emoji: string; label: string; highlight?: boolean; } const HOW_TO: Record = { questions: { title: "How to complete this", badges: [ { emoji: "📝", label: "Take a Practice Sheet", highlight: true }, { emoji: "⚡", label: "Try Drills Mode" }, { emoji: "🎯", label: "Aim for 10+ per session" }, { emoji: "🔄", label: "Retry completed sheets" }, ], }, accuracy: { title: "How to complete this", badges: [ { emoji: "🐢", label: "Slow down, read carefully", highlight: true }, { emoji: "📖", label: "Review wrong answers" }, { emoji: "🎯", label: "Do targeted practice", highlight: true }, { emoji: "💡", label: "Use process of elimination" }, ], }, streak: { title: "How to complete this", badges: [ { emoji: "📅", label: "Practice every day", highlight: true }, { emoji: "⏰", label: "Set a daily reminder" }, { emoji: "🔥", label: "Even 5 mins counts" }, { emoji: "🛡️", label: "Use a Streak Shield" }, ], }, sessions: { title: "How to complete this", badges: [ { emoji: "📝", label: "Start a Practice Sheet", highlight: true }, { emoji: "⚡", label: "Complete Drills" }, { emoji: "🏃", label: "Short sessions count too" }, { emoji: "📚", label: "Try different modules" }, ], }, topics: { title: "How to complete this", badges: [ { emoji: "🗺️", label: "Explore new modules", highlight: true }, { emoji: "📊", label: "Check your weak topics" }, { emoji: "🔍", label: "Use Search to find topics" }, { emoji: "⚡", label: "Drill each topic once" }, ], }, xp: { title: "How to complete this", badges: [ { emoji: "🎯", label: "High accuracy = bonus XP", highlight: true }, { emoji: "🔥", label: "Maintain your streak" }, { emoji: "📝", label: "Complete full sheets", highlight: true }, { emoji: "⚡", label: "Use XP Boosts" }, ], }, leaderboard: { title: "How to complete this", badges: [ { emoji: "📈", label: "Aim for 80%+ accuracy", highlight: true }, { emoji: "🔥", label: "Keep your streak alive" }, { emoji: "📝", label: "Do sessions daily" }, { emoji: "🏆", label: "Check the leaderboard" }, ], }, }; // ─── Island shape configs (mirrors QuestMap SHAPES[0..5]) ───────────────────── interface ShapeConfig { groundClip: string; peakClip: string; sideClip: string; groundW: number; groundH: number; peakW: number; peakH: number; peakBottom: number; } const ISLAND_SHAPES: ShapeConfig[] = [ { groundClip: "ellipse(50% 50% at 50% 50%)", peakClip: "ellipse(50% 50% at 50% 55%)", sideClip: "ellipse(50% 100% at 50% 0%)", groundW: 160, groundH: 38, peakW: 88, peakH: 38, peakBottom: 26, }, { groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)", peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)", sideClip: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)", groundW: 148, groundH: 36, peakW: 72, peakH: 72, peakBottom: 24, }, { groundClip: "ellipse(50% 40% at 50% 58%)", peakClip: "ellipse(50% 38% at 50% 60%)", sideClip: "ellipse(50% 100% at 50% 0%)", groundW: 178, groundH: 30, peakW: 114, peakH: 28, peakBottom: 22, }, { groundClip: "polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)", peakClip: "polygon(50% 0%, 63% 32%, 98% 32%, 71% 54%, 80% 90%, 50% 70%, 20% 90%, 29% 54%, 2% 32%, 37% 32%)", sideClip: "ellipse(50% 100% at 50% 0%)", groundW: 152, groundH: 38, peakW: 80, peakH: 66, peakBottom: 24, }, { groundClip: "path('M 80 10 C 120 5, 150 30, 145 55 C 140 78, 110 88, 80 85 C 55 82, 38 70, 42 55 C 46 42, 62 40, 68 50 C 74 60, 65 70, 55 68 C 38 62, 30 42, 42 28 C 55 12, 70 12, 80 10 Z')", peakClip: "ellipse(38% 55% at 38% 50%)", sideClip: "ellipse(50% 100% at 50% 0%)", groundW: 160, groundH: 36, peakW: 80, peakH: 58, peakBottom: 22, }, { groundClip: "path('M 50 4 C 72 4, 95 28, 95 55 C 95 78, 76 94, 50 94 C 24 94, 5 78, 5 55 C 5 28, 28 4, 50 4 Z')", peakClip: "polygon(50% 0%, 73% 27%, 88% 62%, 68% 98%, 32% 98%, 12% 62%, 27% 27%)", sideClip: "ellipse(50% 100% at 50% 0%)", groundW: 144, groundH: 38, peakW: 76, peakH: 66, peakBottom: 24, }, ]; // ─── Terrain type (mirrors ArcTheme.terrain from QuestMap) ──────────────────── interface StageTerrain { skyTop: string; skyBot: string; seaCol: string; seaHi: string; terrHi: string; terrMid: string; terrLo: string; peakHi: string; peakMid: string; peakLo: string; decos: string[]; } /** * Converts the ArcTheme colours produced by generateArcTheme into the * StageTerrain shape the 3D stage needs. For the three known arcs we keep * hand-tuned sky/sea values; for unknown arcs we derive them from the theme. */ const KNOWN_STAGE_TERRAIN: Record = { east_blue: { skyTop: "#0a1628", skyBot: "#0d2240", seaCol: "#0a3d5c", seaHi: "#1a6a8a", terrHi: "#5eead4", terrMid: "#0d9488", terrLo: "#0f5c55", peakHi: "#a7f3d0", peakMid: "#34d399", peakLo: "#065f46", decos: ["🌴", "🌿"], }, alabasta: { skyTop: "#1c0a00", skyBot: "#3d1a00", seaCol: "#7c3a00", seaHi: "#c26010", terrHi: "#fde68a", terrMid: "#d97706", terrLo: "#78350f", peakHi: "#fef3c7", peakMid: "#fbbf24", peakLo: "#92400e", decos: ["🌵", "🏺"], }, skypiea: { skyTop: "#1a0033", skyBot: "#2e0050", seaCol: "#4c1d95", seaHi: "#7c3aed", terrHi: "#e9d5ff", terrMid: "#a855f7", terrLo: "#581c87", peakHi: "#f5d0fe", peakMid: "#d946ef", peakLo: "#701a75", decos: ["☁️", "✨"], }, }; /** Derive a StageTerrain from a generated arc theme for unknown arc ids. */ const terrainFromTheme = (arcId: string, arc: QuestArc): StageTerrain => { if (KNOWN_STAGE_TERRAIN[arcId]) return KNOWN_STAGE_TERRAIN[arcId]; const theme = generateArcTheme(arc); return { // Sky: very dark version of the theme bg colours skyTop: theme.bgFrom, skyBot: theme.bgTo, // Sea: use accentDark as the deep sea colour, accent as the highlight seaCol: theme.accentDark, seaHi: theme.accent, // Terrain: map terrain colours directly terrHi: theme.terrain.l, terrMid: theme.terrain.m, terrLo: theme.terrain.d, // Peak: lighten accent for highlights, use terrain dark for shadow peakHi: theme.accent, peakMid: theme.terrain.m, peakLo: theme.terrain.d, decos: theme.decos.slice(0, 2), }; }; // ─── 3D Island Stage ────────────────────────────────────────────────────────── const IslandStage = ({ arc, arcId, status, nodeIndex, }: { arc: QuestArc; arcId: string; status: string; nodeIndex: number; }) => { const t = terrainFromTheme(arcId, arc); const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length]; const isCompleted = status === "completed"; const isClaimable = status === "claimable"; const isActive = status === "active"; const isLocked = status === "locked"; const vars = { "--sky-top": t.skyTop, "--sky-bot": t.skyBot, "--sea-col": t.seaCol, "--sea-hi": t.seaHi, "--terr-hi": t.terrHi, "--terr-mid": t.terrMid, "--terr-lo": t.terrLo, "--peak-hi": t.peakHi, "--peak-mid": t.peakMid, "--peak-lo": t.peakLo, } as React.CSSProperties; return (
{/* Clouds */} {[ { w: 70, h: 22, top: 14, delay: 0, dur: 16 }, { w: 50, h: 16, top: 28, delay: -7, dur: 22 }, { w: 40, h: 14, top: 10, delay: -3, dur: 28 }, ].map((c, i) => (
))} {/* Sea + waves */}
{/* Ripple rings */}
{/* 3D island */}
{!isLocked && (
)} {/* Decorations */} {!isLocked && t.decos.map((deco, di) => (
{deco}
))} {isActive && (
)} {isClaimable && (
📦
)} {isLocked && (
🔒
)}
{/* Sparkles for completed */} {isCompleted && [ { left: "30%", top: "18%", sdur: "2s", sdel: "0s" }, { left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" }, { left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" }, { left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" }, ].map((s, i) => ( ))} {/* Lock overlay tint */} {isLocked && (
🔒
)}
); }; // ─── Main component ─────────────────────────────────────────────────────────── interface Props { node: QuestNode; arc: QuestArc; // full arc object needed for theme generation arcAccent: string; arcDark: string; arcId?: string; nodeIndex?: number; onClose: () => void; onClaim: () => void; } export const QuestNodeModal = ({ node, arc, arcAccent, arcId = "east_blue", nodeIndex = 0, onClose, onClaim, }: Props) => { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); // ── New field names ────────────────────────────────────────────────────── const progress = Math.min( 100, Math.round((node.current_value / node.req_target) * 100), ); const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type; const howTo = HOW_TO[node.req_type]; const remaining = Math.max(0, node.req_target - node.current_value); const isClaimable = node.status === "claimable"; const isLocked = node.status === "locked"; const isCompleted = node.status === "completed"; const isActive = node.status === "active"; return (
e.stopPropagation()}> {/* Handle + close */}
{/* 3D island stage — now receives full arc for theme generation */} {/* Scrollable content */}
{/* Title block */}
{/* req_type replaces node.requirement.type */}
{reqIcon(node.req_type)} Quest
{/* node.name replaces node.title */}

{node.name ?? "—"}

{/* node.islandName removed — reuse node.name as location label */}

📍 {node.name ?? "—"}

{/* Flavour — node.description replaces node.flavourText */} {node.description && (

{node.description}

)} {/* Objective */}
⚓ Objective {!isLocked && ( {isCompleted ? "✅ Done" : `${progress}%`} )}
{reqIcon(node.req_type)}
{/* req_target + derived label replace node.requirement.target/label */}

{node.req_target} {reqLabel}

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

{/* Progress bar */} {!isLocked && ( <>
{/* current_value / req_target replace old progress / requirement.target */}
{node.current_value} {node.req_target}
)} {/* How-to badges */} {(isActive || isClaimable) && howTo && ( <>

🧭 {howTo.title}

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

Quest Locked

Complete the previous island to sail here

)} {/* Reward — sources from flat node reward fields */}

📦 Treasure Chest

{/* reward_coins replaces node.reward.xp */} {node.reward_coins > 0 && (
🪙 +{node.reward_coins}
)} {/* reward_title is now a nested object, not a string */} {node.reward_title?.name && (
🏴‍☠️ {node.reward_title.name}
)} {/* reward_items is now an array — show one pill per item */} {node.reward_items?.map((inv) => (
🎁 {inv.item.name}
))}
{/* Footer CTA */}
{isClaimable ? ( ) : isCompleted ? (

✅ Completed — treasure claimed!

) : isLocked ? (

🔒 Locked — keep sailing

) : ( /* remaining replaces node.requirement.target - node.progress */

{progress}% complete · {remaining} {reqLabel} remaining

)}
); };