Files
edbridge-scholars/src/components/QuestNodeModal.tsx
2026-03-12 02:39:34 +06:00

1044 lines
35 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string> = {
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<string, { title: string; badges: Badge[] }> = {
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<string, StageTerrain> = {
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 (
<div className="qnm-stage" style={vars}>
{/* 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) => (
<div
key={i}
className="qnm-cloud"
style={
{
width: c.w,
height: c.h,
top: c.top,
"--cdur": `${c.dur}s`,
animationDelay: `${c.delay}s`,
opacity: isLocked ? 0.08 : 0.18,
} as React.CSSProperties
}
/>
))}
{/* Sea + waves */}
<div className="qnm-sea">
<div
className="qnm-wave"
style={{ "--wdur": "5s" } as React.CSSProperties}
/>
<div
className="qnm-wave"
style={{ "--wdur": "7s" } as React.CSSProperties}
/>
<div
className="qnm-wave"
style={{ "--wdur": "9s" } as React.CSSProperties}
/>
</div>
{/* Ripple rings */}
<div
style={{
position: "absolute",
left: "50%",
bottom: 32,
transform: "translateX(-50%)",
width: 0,
height: 0,
}}
>
<div className="qnm-ripple" />
<div className="qnm-ripple" />
</div>
{/* 3D island */}
<div
className="qnm-island-3d-wrap"
style={{ opacity: isLocked ? 0.3 : 1 }}
>
<div
className="qnm-island-3d"
style={{ animationPlayState: isLocked ? "paused" : "running" }}
>
<div className="qnm-il qnm-il-water" />
<div
className="qnm-il qnm-il-side"
style={{
width: shp.groundW,
marginLeft: -(shp.groundW / 2),
clipPath: shp.sideClip,
}}
/>
<div
className="qnm-il qnm-il-ground"
style={{
width: shp.groundW,
height: shp.groundH,
marginLeft: -(shp.groundW / 2),
clipPath: shp.groundClip,
borderRadius: 0,
}}
/>
{!isLocked && (
<div
className="qnm-il qnm-il-peak"
style={{
width: shp.peakW,
height: shp.peakH,
marginLeft: -(shp.peakW / 2),
bottom: shp.peakBottom,
clipPath: shp.peakClip,
}}
/>
)}
{/* Decorations */}
{!isLocked &&
t.decos.map((deco, di) => (
<div
key={di}
className="qnm-il-deco"
style={{
marginLeft: di === 0 ? "-30px" : "8px",
animationDelay: `${di * 0.6}s`,
}}
>
<span className="qnm-deco-emoji">{deco}</span>
</div>
))}
{isActive && (
<div className="qnm-il-flag">
<div className="qnm-flag-pole" />
<div className="qnm-flag-cloth" />
</div>
)}
{isClaimable && (
<div className="qnm-il-deco" style={{ marginLeft: "-12px" }}>
<span
className="qnm-deco-emoji"
style={{
animation: "qnmPeakBob 1s ease-in-out infinite",
fontSize: "1.6rem",
}}
>
📦
</span>
</div>
)}
{isLocked && (
<div
style={{
position: "absolute",
left: "50%",
bottom: 50,
transform: "translateX(-50%) translateZ(30px)",
fontSize: "2rem",
opacity: 0.5,
}}
>
🔒
</div>
)}
</div>
</div>
{/* 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) => (
<span
key={i}
className="qnm-star"
style={
{
left: s.left,
top: s.top,
"--sdur": s.sdur,
"--sdel": s.sdel,
} as React.CSSProperties
}
>
</span>
))}
{/* Lock overlay tint */}
{isLocked && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.45)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ fontSize: "2.5rem", opacity: 0.6 }}>🔒</span>
</div>
)}
</div>
);
};
// ─── 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 (
<div
className="qnm-overlay"
onClick={onClose}
style={{ "--ac": arcAccent } as React.CSSProperties}
>
<style>{STYLES}</style>
<div className="qnm-sheet" onClick={(e) => e.stopPropagation()}>
{/* Handle + close */}
<div className="qnm-handle-row">
<div className="qnm-handle" />
</div>
<button className="qnm-close" onClick={onClose}>
<X size={13} color="rgba(255,255,255,0.5)" />
</button>
{/* 3D island stage — now receives full arc for theme generation */}
<IslandStage
arc={arc}
arcId={arcId}
status={node.status}
nodeIndex={nodeIndex}
/>
{/* Scrollable content */}
<div className="qnm-body">
{/* Title block */}
<div className="qnm-title-block">
{/* req_type replaces node.requirement.type */}
<div className="qnm-arc-tag">{reqIcon(node.req_type)} Quest</div>
{/* node.name replaces node.title */}
<h2 className="qnm-quest-title">{node.name ?? "—"}</h2>
{/* node.islandName removed — reuse node.name as location label */}
<p className="qnm-island-name">📍 {node.name ?? "—"}</p>
</div>
{/* Flavour — node.description replaces node.flavourText */}
{node.description && (
<div className="qnm-flavour">
<p className="qnm-flavour-text">{node.description}</p>
</div>
)}
{/* Objective */}
<div className="qnm-obj-card">
<div className="qnm-obj-header">
<span className="qnm-obj-label"> Objective</span>
{!isLocked && (
<span className="qnm-obj-pct">
{isCompleted ? "✅ Done" : `${progress}%`}
</span>
)}
</div>
<div className="qnm-obj-row">
<div className="qnm-obj-icon">{reqIcon(node.req_type)}</div>
<div>
{/* req_target + derived label replace node.requirement.target/label */}
<p className="qnm-obj-text">
{node.req_target} {reqLabel}
</p>
<p className="qnm-obj-sub">
{isCompleted
? "✅ Completed — treasure claimed!"
: isLocked
? "🔒 Complete previous quests first"
: `${node.current_value} / ${node.req_target} done`}
</p>
</div>
</div>
{/* Progress bar */}
{!isLocked && (
<>
<div className="qnm-bar-track">
<div
className="qnm-bar-fill"
style={{ width: mounted ? `${progress}%` : "0%" }}
/>
</div>
{/* current_value / req_target replace old progress / requirement.target */}
<div className="qnm-bar-nums">
<span>{node.current_value}</span>
<span>{node.req_target}</span>
</div>
</>
)}
{/* How-to badges */}
{(isActive || isClaimable) && howTo && (
<>
<p className="qnm-howto-label" style={{ marginTop: "0.75rem" }}>
🧭 {howTo.title}
</p>
<div className="qnm-howto-badges">
{howTo.badges.map((b, bi) => (
<div
key={bi}
className={`qnm-howto-badge${b.highlight ? " hi" : ""}`}
style={
{ "--bdel": `${bi * 0.06}s` } as React.CSSProperties
}
>
<span>{b.emoji}</span>
<span>{b.label}</span>
</div>
))}
</div>
</>
)}
</div>
{/* Locked banner */}
{isLocked && (
<div className="qnm-locked-banner">
<div className="qnm-locked-icon">
<Lock size={18} color="rgba(255,255,255,0.3)" />
</div>
<div>
<p className="qnm-locked-text">Quest Locked</p>
<p className="qnm-locked-sub">
Complete the previous island to sail here
</p>
</div>
</div>
)}
{/* Reward — sources from flat node reward fields */}
<div className="qnm-reward-card">
<p className="qnm-reward-label">📦 Treasure Chest</p>
<div className="qnm-reward-row">
{/* reward_coins replaces node.reward.xp */}
{node.reward_coins > 0 && (
<div className="qnm-reward-pill">🪙 +{node.reward_coins}</div>
)}
{/* reward_title is now a nested object, not a string */}
{node.reward_title?.name && (
<div className="qnm-reward-pill">
🏴 {node.reward_title.name}
</div>
)}
{/* reward_items is now an array — show one pill per item */}
{node.reward_items?.map((inv) => (
<div key={inv.id} className="qnm-reward-pill">
🎁 {inv.item.name}
</div>
))}
</div>
</div>
</div>
{/* Footer CTA */}
<div className="qnm-footer">
{isClaimable ? (
<button className="qnm-claim-btn" onClick={onClaim}>
Open Your Treasure Chest
</button>
) : isCompleted ? (
<p className="qnm-note"> Completed treasure claimed!</p>
) : isLocked ? (
<p className="qnm-note">🔒 Locked keep sailing</p>
) : (
/* remaining replaces node.requirement.target - node.progress */
<p className="qnm-note">
{progress}% complete · {remaining} {reqLabel} remaining
</p>
)}
</div>
</div>
</div>
);
};