Files
edbridge-scholars/src/components/QuestNodeModal.tsx

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