web #1

Merged
shafin808s merged 35 commits from web into main 2026-03-11 20:41:06 +00:00
45 changed files with 14807 additions and 2568 deletions
Showing only changes of commit 2eaf77e13c - Show all commits

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import type { QuestNode } from "../types/quest"; import type { QuestNode, ClaimedRewardResponse } from "../types/quest";
// ─── Styles ─────────────────────────────────────────────────────────────────── // ─── Styles ───────────────────────────────────────────────────────────────────
const S = ` const S = `
@ -327,6 +327,14 @@ const S = `
background:rgba(251,191,36,0.06); background:rgba(251,191,36,0.06);
} }
/* Loading state inside reward area */
.com-rewards-loading {
font-family:'Cinzel',serif;
font-size:0.72rem; font-weight:700; color:rgba(251,191,36,0.4);
text-align:center; padding:1rem 0; letter-spacing:0.1em;
animation:comPulse 1.2s ease-in-out infinite;
}
/* ── CTA button ── */ /* ── CTA button ── */
.com-cta { .com-cta {
width:100%; padding:1rem; width:100%; padding:1rem;
@ -369,14 +377,12 @@ const PARTICLE_COLORS = [
const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"]; const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"];
const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"]; const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"];
// Rays at evenly spaced angles
const RAYS = Array.from({ length: 12 }, (_, i) => ({ const RAYS = Array.from({ length: 12 }, (_, i) => ({
id: i, id: i,
angle: `${(i / 12) * 360}deg`, angle: `${(i / 12) * 360}deg`,
delay: `${i * 0.04}s`, delay: `${i * 0.04}s`,
})); }));
// Burst rings
const BURST_RINGS = [ const BURST_RINGS = [
{ id: 0, size: "3", dur: "0.7s", delay: "0s" }, { id: 0, size: "3", dur: "0.7s", delay: "0s" },
{ id: 1, size: "5", dur: "0.9s", delay: "0.1s" }, { id: 1, size: "5", dur: "0.9s", delay: "0.1s" },
@ -384,7 +390,6 @@ const BURST_RINGS = [
{ id: 3, size: "12", dur: "1.4s", delay: "0.3s" }, { id: 3, size: "12", dur: "1.4s", delay: "0.3s" },
]; ];
// Stars in background — stable between renders
const STARS = Array.from({ length: 40 }, (_, i) => ({ const STARS = Array.from({ length: 40 }, (_, i) => ({
id: i, id: i,
w: 1 + ((i * 7) % 3), w: 1 + ((i * 7) % 3),
@ -394,7 +399,6 @@ const STARS = Array.from({ length: 40 }, (_, i) => ({
delay: `${(i * 7) % 3}s`, delay: `${(i * 7) % 3}s`,
})); }));
// Sparkles floating around the revealed card
const SPARKLES = Array.from({ length: 8 }, (_, i) => ({ const SPARKLES = Array.from({ length: 8 }, (_, i) => ({
id: i, id: i,
emoji: SPARKLE_EMOJIS[i % 4], emoji: SPARKLE_EMOJIS[i % 4],
@ -409,10 +413,11 @@ type Phase = "idle" | "shaking" | "opening" | "revealed";
interface Props { interface Props {
node: QuestNode; node: QuestNode;
claimResult: ClaimedRewardResponse | null;
onClose: () => void; onClose: () => void;
} }
export const ChestOpenModal = ({ node, onClose }: Props) => { export const ChestOpenModal = ({ node, claimResult, onClose }: Props) => {
const [phase, setPhase] = useState<Phase>("idle"); const [phase, setPhase] = useState<Phase>("idle");
const [showXP, setShowXP] = useState(false); const [showXP, setShowXP] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -464,40 +469,55 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
[], [],
); );
const rewards = [ // ── Build reward rows from ClaimedRewardResponse ──────────────────────────
// claimResult may be null while the API call is in flight; we show a loading
// state in that case rather than crashing or showing stale data.
const xpAwarded = claimResult?.xp_awarded ?? 0;
// Defensively coerce to arrays — the API may return null, a single object,
// or omit these fields entirely rather than returning an empty array.
const titlesAwarded = Array.isArray(claimResult?.title_unlocked)
? claimResult!.title_unlocked
: claimResult?.title_unlocked
? [claimResult.title_unlocked]
: [];
const itemsAwarded = Array.isArray(claimResult?.items_awarded)
? claimResult!.items_awarded
: claimResult?.items_awarded
? [claimResult.items_awarded]
: [];
const rewards = claimResult
? [
// XP row — always present
{ {
key: "xp", key: "xp",
cls: "xp-row", cls: "xp-row",
icon: "⚡", icon: "⚡",
lbl: "XP Gained", lbl: "XP Gained",
val: `+${node.reward.xp} XP`, val: `+${xpAwarded} XP`,
delay: "0.05s", delay: "0.05s",
}, },
...(node.reward.title // One row per unlocked title (usually 0 or 1)
? [ ...titlesAwarded.map((t, i) => ({
{ key: `title-${t.id}`,
key: "title",
cls: "", cls: "",
icon: "🏴‍☠️", icon: "🏴‍☠️",
lbl: "Crew Title", lbl: "Crew Title",
val: node.reward.title, val: t.name,
delay: "0.15s", delay: `${0.1 + i * 0.1}s`,
}, })),
] // One row per awarded item
: []), ...itemsAwarded.map((inv, i) => ({
...(node.reward.itemLabel key: `item-${inv.id}`,
? [
{
key: "item",
cls: "", cls: "",
icon: "🎁", icon: "🎁",
lbl: "Item", lbl: inv.item.type ?? "Item",
val: node.reward.itemLabel, val: inv.item.name,
delay: "0.25s", delay: `${0.1 + (titlesAwarded.length + i) * 0.1}s`,
}, })),
] ]
: []), : [];
];
const chestClass = const chestClass =
phase === "idle" phase === "idle"
@ -534,7 +554,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
/> />
))} ))}
{/* Crepuscular rays (appear on open) */} {/* Crepuscular rays */}
{(phase === "opening" || phase === "revealed") && ( {(phase === "opening" || phase === "revealed") && (
<div className="com-rays"> <div className="com-rays">
{RAYS.map((r) => ( {RAYS.map((r) => (
@ -639,8 +659,12 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
</div> </div>
))} ))}
{/* XP blast */} {/* XP blast — uses xp_awarded from claimResult */}
{showXP && <div className="com-xp-blast">+{node.reward.xp} XP</div>} {showXP && (
<div className="com-xp-blast">
{xpAwarded > 0 ? `+${xpAwarded} XP` : "✨"}
</div>
)}
{/* Card */} {/* Card */}
<div className="com-card" onClick={(e) => e.stopPropagation()}> <div className="com-card" onClick={(e) => e.stopPropagation()}>
@ -671,16 +695,24 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
<p className="com-tap-sub">YOUR HARD WORK HAS PAID OFF, PIRATE</p> <p className="com-tap-sub">YOUR HARD WORK HAS PAID OFF, PIRATE</p>
</> </>
)} )}
{phase === "shaking" && ( {phase === "shaking" && (
<> <>
<p className="com-shake-text">The chest stirs...</p> <p className="com-shake-text">The chest stirs...</p>
<p className="com-shake-dots"> </p> <p className="com-shake-dots"> </p>
</> </>
)} )}
{phase === "revealed" && ( {phase === "revealed" && (
<> <>
<p className="com-rewards-title"> Spoils of Victory</p> <p className="com-rewards-title"> Spoils of Victory</p>
<div className="com-rewards"> <div className="com-rewards">
{/* claimResult not yet available — API still in flight */}
{!claimResult && (
<p className="com-rewards-loading">
Counting your spoils...
</p>
)}
{rewards.map((r) => ( {rewards.map((r) => (
<div <div
key={r.key} key={r.key}
@ -697,7 +729,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
</div> </div>
<button <button
className="com-cta" className="com-cta"
style={{ animationDelay: rewards.length * 0.1 + "s" }} style={{ animationDelay: `${rewards.length * 0.1}s` }}
onClick={onClose} onClick={onClose}
> >
Set Sail Set Sail
@ -707,7 +739,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
</div> </div>
</div> </div>
{/* Skip link for impatient pirates */} {/* Skip link */}
{phase === "revealed" && ( {phase === "revealed" && (
<p className="com-skip" onClick={onClose}> <p className="com-skip" onClick={onClose}>
tap anywhere to continue tap anywhere to continue

View File

@ -6,14 +6,41 @@ import {
useQuestStore, useQuestStore,
getQuestSummary, getQuestSummary,
getCrewRank, getCrewRank,
getEarnedXP,
} from "../stores/useQuestStore"; } from "../stores/useQuestStore";
import type { QuestNode, QuestArc } from "../types/quest"; import type {
QuestNode,
QuestArc,
ClaimedRewardResponse,
} from "../types/quest";
import { CREW_RANKS } from "../types/quest"; import { CREW_RANKS } from "../types/quest";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
import { PredictedScoreCard } from "./PredictedScoreCard"; import { PredictedScoreCard } from "./PredictedScoreCard";
import { ChestOpenModal } from "./ChestOpenModal"; import { ChestOpenModal } from "./ChestOpenModal";
// Re-use the same theme generator that QuestMap uses so arc colours are consistent
import { generateArcTheme } from "../pages/student/QuestMap";
import { InventoryButton } from "./InventoryButton";
// ─── Requirement helpers (mirrors QuestMap) ───────────────────────────────────
const REQ_EMOJI: Record<string, string> = {
questions: "❓",
accuracy: "🎯",
streak: "🔥",
sessions: "📚",
topics: "🗺️",
xp: "⚡",
leaderboard: "🏆",
};
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",
};
// ─── Styles ─────────────────────────────────────────────────────────────────── // ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = ` const STYLES = `
@ -196,8 +223,6 @@ const STYLES = `
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
margin-bottom: 12px; margin-bottom: 12px;
} }
/* Animated sea shimmer */
.hc-ext::before { .hc-ext::before {
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0; content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
background: background:
@ -210,16 +235,12 @@ const STYLES = `
0% { background-position: 0% 0%, 100% 0%; } 0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; } 100% { background-position: 100% 100%, 0% 100%; }
} }
/* Gold orb */
.hc-ext::after { .hc-ext::after {
content: ''; position: absolute; top: -40px; right: -30px; z-index: 0; content: ''; position: absolute; top: -40px; right: -30px; z-index: 0;
width: 180px; height: 180px; border-radius: 50%; width: 180px; height: 180px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%); background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%);
pointer-events: none; pointer-events: none;
} }
/* Header */
.hc-ext-header { .hc-ext-header {
position: relative; z-index: 2; position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
@ -235,8 +256,6 @@ const STYLES = `
border: 1px solid rgba(251,191,36,0.18); border-radius: 100px; border: 1px solid rgba(251,191,36,0.18); border-radius: 100px;
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
} }
/* Scrollable track container */
.hc-ext-scroll { .hc-ext-scroll {
position: relative; z-index: 2; position: relative; z-index: 2;
overflow-x: auto; overflow-y: hidden; overflow-x: auto; overflow-y: hidden;
@ -245,26 +264,17 @@ const STYLES = `
} }
.hc-ext-scroll::-webkit-scrollbar { display: none; } .hc-ext-scroll::-webkit-scrollbar { display: none; }
.hc-ext-scroll:active { cursor: grabbing; } .hc-ext-scroll:active { cursor: grabbing; }
/* Track inner wrapper — the thing that actually lays out rank nodes */
.hc-ext-inner { .hc-ext-inner {
display: flex; align-items: flex-end; display: flex; align-items: flex-end;
position: relative; position: relative;
/* height: ship(28px) + gap(14px) + node(52px) + label(36px) = ~130px */
height: 110px; height: 110px;
/* width set inline per node count */
} }
/* Baseline connector line — full width, dim */
.hc-ext-baseline { .hc-ext-baseline {
position: absolute; position: absolute;
top: 56px; /* ship(28) + gap(14) + half of node(26) — sits at node centre */ top: 56px; left: 26px; right: 26px; height: 2px;
left: 26px; right: 26px; height: 2px;
background: rgba(255,255,255,0.07); background: rgba(255,255,255,0.07);
border-radius: 2px; z-index: 0; border-radius: 2px; z-index: 0;
} }
/* Gold progress line — width set inline */
.hc-ext-progress-line { .hc-ext-progress-line {
position: absolute; position: absolute;
top: 56px; left: 26px; height: 2px; top: 56px; left: 26px; height: 2px;
@ -273,12 +283,9 @@ const STYLES = `
border-radius: 2px; z-index: 1; border-radius: 2px; z-index: 1;
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1); transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
} }
/* Ship — absolutely positioned, transition on 'left' */
.hc-ext-ship-wrap { .hc-ext-ship-wrap {
position: absolute; position: absolute;
top: 25px; /* sits at top of inner, ship 28px + gap 14px = 42px to node top (56px centre) */ top: 25px; z-index: 10; pointer-events: none;
z-index: 10; pointer-events: none;
display: flex; flex-direction: column; align-items: center; gap: 0px; display: flex; flex-direction: column; align-items: center; gap: 0px;
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1); transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
transform: translateX(-50%); transform: translateX(-50%);
@ -297,23 +304,18 @@ const STYLES = `
width: 1px; height: 14px; width: 1px; height: 14px;
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent); background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
} }
/* Each rank column */
.hc-ext-col { .hc-ext-col {
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
position: relative; z-index: 2; position: relative; z-index: 2;
width: 88px; flex-shrink: 0; width: 88px; flex-shrink: 0;
} }
/* Narrow first/last columns so line extends correctly */
.hc-ext-col:first-child, .hc-ext-col:first-child,
.hc-ext-col:last-child { width: 52px; } .hc-ext-col:last-child { width: 52px; }
/* Node circle */
.hc-ext-node { .hc-ext-node {
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0; width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; position: relative; z-index: 2; font-size: 1.4rem; position: relative; z-index: 2;
margin-top: 42px; /* push down below ship zone */ margin-top: 42px;
} }
.hc-ext-node.reached { .hc-ext-node.reached {
background: linear-gradient(145deg, #1e0e4a, #3730a3); background: linear-gradient(145deg, #1e0e4a, #3730a3);
@ -334,12 +336,10 @@ const STYLES = `
50% { box-shadow: 0 0 0 7px rgba(251,191,36,0.06), 0 0 30px rgba(168,85,247,0.6), 0 4px 0 rgba(80,30,150,0.5); } 50% { box-shadow: 0 0 0 7px rgba(251,191,36,0.06), 0 0 30px rgba(168,85,247,0.6), 0 4px 0 rgba(80,30,150,0.5); }
} }
.hc-ext-node.locked { .hc-ext-node.locked {
background: rgba(0,0,0); background: rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.09); border: 2px solid rgba(255,255,255,0.09);
filter: grayscale(0.7) opacity(0.45); filter: grayscale(0.7) opacity(0.45);
} }
/* Labels below node */
.hc-ext-label { .hc-ext-label {
margin-top: 7px; margin-top: 7px;
display: flex; flex-direction: column; align-items: center; gap: 2px; display: flex; flex-direction: column; align-items: center; gap: 2px;
@ -358,8 +358,6 @@ const STYLES = `
.hc-ext-label-xp.reached { color: rgba(251,191,36,0.4); } .hc-ext-label-xp.reached { color: rgba(251,191,36,0.4); }
.hc-ext-label-xp.current { color: rgba(192,132,252,0.6); } .hc-ext-label-xp.current { color: rgba(192,132,252,0.6); }
.hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); } .hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); }
/* Footer link */
.hc-ext-footer { .hc-ext-footer {
position: relative; z-index: 2; position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center; gap: 0.3rem; display: flex; align-items: center; justify-content: center; gap: 0.3rem;
@ -372,13 +370,14 @@ const STYLES = `
.hc-ext-footer:hover { opacity: 0.75; } .hc-ext-footer:hover { opacity: 0.75; }
`; `;
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function getActiveQuests(arcs: QuestArc[]) { function getActiveQuests(arcs: QuestArc[]) {
const out: { node: QuestNode; arc: QuestArc }[] = []; const out: { node: QuestNode; arc: QuestArc }[] = [];
for (const arc of arcs) for (const arc of arcs)
for (const node of arc.nodes) for (const node of arc.nodes)
if (node.status === "claimable" || node.status === "active") if (node.status === "claimable" || node.status === "active")
out.push({ node, arc }); out.push({ node, arc });
// Claimable nodes bubble to the top
out.sort((a, b) => out.sort((a, b) =>
a.node.status === "claimable" && b.node.status !== "claimable" a.node.status === "claimable" && b.node.status !== "claimable"
? -1 ? -1
@ -389,10 +388,8 @@ function getActiveQuests(arcs: QuestArc[]) {
return out.slice(0, 2); return out.slice(0, 2);
} }
// Segment width for nodes that aren't first/last
const SEG_W = 88; const SEG_W = 88;
const EDGE_W = 52; const EDGE_W = 52;
// Centre x of node at index i (0-based, total N nodes)
function nodeX(i: number, total: number): number { function nodeX(i: number, total: number): number {
if (i === 0) return EDGE_W / 2; if (i === 0) return EDGE_W / 2;
if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2; if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
@ -402,7 +399,6 @@ function nodeX(i: number, total: number): number {
// ─── QUEST_EXTENDED sub-component ──────────────────────────────────────────── // ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
const RankLadder = ({ const RankLadder = ({
earnedXP, earnedXP,
onViewAll,
}: { }: {
earnedXP: number; earnedXP: number;
onViewAll: () => void; onViewAll: () => void;
@ -411,7 +407,6 @@ const RankLadder = ({
const ladder = [...CREW_RANKS] as typeof CREW_RANKS; const ladder = [...CREW_RANKS] as typeof CREW_RANKS;
const N = ladder.length; const N = ladder.length;
// Which rank the user is currently on (0-based)
let currentIdx = 0; let currentIdx = 0;
for (let i = N - 1; i >= 0; i--) { for (let i = N - 1; i >= 0; i--) {
if (earnedXP >= ladder[i].xpRequired) { if (earnedXP >= ladder[i].xpRequired) {
@ -430,19 +425,13 @@ const RankLadder = ({
) )
: 1; : 1;
// Ship x position: interpolate between current node and next node
const shipX = nextRank const shipX = nextRank
? nodeX(currentIdx, N) + ? nodeX(currentIdx, N) +
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext (nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext
: nodeX(currentIdx, N); : nodeX(currentIdx, N);
// Gold progress line width: from left edge to ship position
const progressLineW = shipX; const progressLineW = shipX;
// Total scroll width
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W; const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
// Animate ship in after mount
const [animated, setAnimated] = useState(false); const [animated, setAnimated] = useState(false);
useEffect(() => { useEffect(() => {
const id = requestAnimationFrame(() => const id = requestAnimationFrame(() =>
@ -451,13 +440,13 @@ const RankLadder = ({
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, []); }, []);
// Auto-scroll to ship position on mount
useEffect(() => { useEffect(() => {
if (!scrollRef.current) return; if (!scrollRef.current) return;
const el = scrollRef.current; const el = scrollRef.current;
const containerW = el.offsetWidth; el.scrollTo({
const targetScroll = shipX - containerW / 2; left: Math.max(0, shipX - el.offsetWidth / 2),
el.scrollTo({ left: Math.max(0, targetScroll), behavior: "smooth" }); behavior: "smooth",
});
}, [shipX]); }, [shipX]);
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100; const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
@ -467,13 +456,11 @@ const RankLadder = ({
return ( return (
<div className="hc-ext"> <div className="hc-ext">
{/* Header */}
<div className="hc-ext-header"> <div className="hc-ext-header">
<span className="hc-ext-title"> Crew Rank</span> <span className="hc-ext-title"> Crew Rank</span>
<span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span> <span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span>
</div> </div>
{/* Current rank label */}
<div <div
style={{ style={{
position: "relative", position: "relative",
@ -507,19 +494,13 @@ const RankLadder = ({
</span> </span>
</div> </div>
{/* Scrollable rank track */}
<div className="hc-ext-scroll" ref={scrollRef}> <div className="hc-ext-scroll" ref={scrollRef}>
<div className="hc-ext-inner" style={{ width: totalW }}> <div className="hc-ext-inner" style={{ width: totalW }}>
{/* Baseline dim line */}
<div className="hc-ext-baseline" /> <div className="hc-ext-baseline" />
{/* Gold progress line */}
<div <div
className="hc-ext-progress-line" className="hc-ext-progress-line"
style={{ width: animated ? progressLineW : 26 }} style={{ width: animated ? progressLineW : 26 }}
/> />
{/* Ship marker */}
<div <div
className="hc-ext-ship-wrap" className="hc-ext-ship-wrap"
style={{ left: animated ? shipX : nodeX(0, N) }} style={{ left: animated ? shipX : nodeX(0, N) }}
@ -529,8 +510,6 @@ const RankLadder = ({
</span> </span>
<div className="hc-ext-ship-tether" /> <div className="hc-ext-ship-tether" />
</div> </div>
{/* Rank nodes */}
{ladder.map((r, i) => { {ladder.map((r, i) => {
const state = const state =
i < currentIdx i < currentIdx
@ -556,19 +535,12 @@ const RankLadder = ({
})} })}
</div> </div>
</div> </div>
{/* Footer */}
{/* <div className="hc-ext-footer" onClick={onViewAll}>
<Map size={12} />
View quest map
</div> */}
</div> </div>
); );
}; };
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED"; type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED";
interface Props { interface Props {
onViewAll?: () => void; onViewAll?: () => void;
mode?: Mode; mode?: Mode;
@ -578,17 +550,22 @@ interface Props {
export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => { export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
// Select all needed store slices — earnedXP and earnedTitles are now first-class state
const arcs = useQuestStore((s) => s.arcs); const arcs = useQuestStore((s) => s.arcs);
const earnedXP = user?.total_xp ?? 0;
const earnedTitles = useQuestStore((s) => s.earnedTitles);
const claimNode = useQuestStore((s) => s.claimNode); const claimNode = useQuestStore((s) => s.claimNode);
const summary = getQuestSummary(arcs); // Updated signatures: getQuestSummary needs earnedXP + earnedTitles,
const rank = getCrewRank(arcs); // getCrewRank takes earnedXP directly (no longer iterates nodes)
const earnedXP = getEarnedXP(arcs); const summary = getQuestSummary(arcs, earnedXP, earnedTitles);
const rank = getCrewRank(earnedXP);
const activeQuests = getActiveQuests(arcs); const activeQuests = getActiveQuests(arcs);
const u = user as any; const u = user as any;
const level = u?.current_level ?? u?.level ?? 1; const level = u?.current_level ?? 1;
const totalXP = u?.total_xp ?? u?.xp ?? 0; const totalXP = u?.total_xp ?? 5;
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0; const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
const levelEnd = const levelEnd =
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000; u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
@ -621,17 +598,31 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
node: QuestNode; node: QuestNode;
arcId: string; arcId: string;
} | null>(null); } | null>(null);
// Holds the API response from the claim call so ChestOpenModal can display real rewards
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
null,
);
const handleViewAll = () => { const handleViewAll = () => {
if (onViewAll) onViewAll(); if (onViewAll) onViewAll();
else navigate("/student/quests"); else navigate("/student/quests");
}; };
const handleClaim = (node: QuestNode, arcId: string) =>
const handleClaim = (node: QuestNode, arcId: string) => {
setClaimResult(null); // clear any previous result before opening
setClaimingNode({ node, arcId }); setClaimingNode({ node, arcId });
};
const handleChestClose = () => { const handleChestClose = () => {
if (!claimingNode) return; if (!claimingNode) return;
claimNode(claimingNode.arcId, claimingNode.node.id); claimNode(
claimingNode.arcId,
claimingNode.node.node_id, // node_id replaces old id
claimResult?.xp_awarded ?? 0,
claimResult?.title_unlocked.map((t) => t.name) ?? [],
);
setClaimingNode(null); setClaimingNode(null);
setClaimResult(null);
}; };
const rankProgress = Math.round(rank.progressToNext * 100); const rankProgress = Math.round(rank.progressToNext * 100);
@ -644,14 +635,17 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
const showQuestCompact = mode === "DEFAULT" || mode === "QUEST_COMPACT"; const showQuestCompact = mode === "DEFAULT" || mode === "QUEST_COMPACT";
const showQuestExtended = mode === "QUEST_EXTENDED"; const showQuestExtended = mode === "QUEST_EXTENDED";
// QUEST_EXTENDED renders its own standalone dark card — no .hc-card wrapper
if (showQuestExtended) { if (showQuestExtended) {
return ( return (
<> <>
<style>{STYLES}</style> <style>{STYLES}</style>
<RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} /> <RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} />
{claimingNode && ( {claimingNode && (
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} /> <ChestOpenModal
node={claimingNode.node}
claimResult={claimResult}
onClose={handleChestClose}
/>
)} )}
</> </>
); );
@ -691,10 +685,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
<p className="hc-role">{roleLabel}</p> <p className="hc-role">{roleLabel}</p>
</div> </div>
</div> </div>
<InventoryButton label="Inventory" />
<Drawer direction="top"> <Drawer direction="top">
<DrawerTrigger asChild> <DrawerTrigger asChild>
<button className="hc-score-btn"> <button className="hc-score-btn">
<Gauge size={14} /> Score <Gauge size={14} />
</button> </button>
</DrawerTrigger> </DrawerTrigger>
<DrawerContent> <DrawerContent>
@ -702,6 +697,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
</div> </div>
<div className="hc-sep" /> <div className="hc-sep" />
</> </>
)} )}
@ -753,35 +749,41 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
<p className="hc-empty"> All caught up keep sailing!</p> <p className="hc-empty"> All caught up keep sailing!</p>
) : ( ) : (
activeQuests.map(({ node, arc }) => { activeQuests.map(({ node, arc }) => {
// Progress uses new field names
const pct = Math.min( const pct = Math.min(
100, 100,
Math.round( Math.round((node.current_value / node.req_target) * 100),
(node.progress / node.requirement.target) * 100,
),
); );
const isClaimable = node.status === "claimable"; const isClaimable = node.status === "claimable";
// Arc accent colour via theme generator — arc.accentColor no longer exists
const accentColor = generateArcTheme(arc).accent;
// Node icon derived from req_type — node.emoji no longer exists
const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️";
// Progress label derived from req_type — node.requirement.label no longer exists
const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type;
return ( return (
<div <div
key={node.id} key={node.node_id} // node_id replaces old id
className="hc-quest-row" className="hc-quest-row"
style={ style={{ "--ac": accentColor } as React.CSSProperties}
{ "--ac": arc.accentColor } as React.CSSProperties
}
onClick={() => !isClaimable && handleViewAll()} onClick={() => !isClaimable && handleViewAll()}
> >
<div <div
className={`hc-q-icon${isClaimable ? " claimable" : ""}`} className={`hc-q-icon${isClaimable ? " claimable" : ""}`}
> >
{isClaimable ? "📦" : node.emoji} {isClaimable ? "📦" : nodeEmoji}
</div> </div>
<div className="hc-q-body"> <div className="hc-q-body">
<p className="hc-q-name">{node.title}</p> {/* node.name replaces old node.title */}
<p className="hc-q-name">{node.name ?? "—"}</p>
{isClaimable ? ( {isClaimable ? (
<p className="hc-q-claimable"> Ready to claim!</p> <p className="hc-q-claimable"> Ready to claim!</p>
) : ( ) : (
<p className="hc-q-sub"> <p className="hc-q-sub">
{node.progress}/{node.requirement.target}{" "} {/* current_value / req_target replace old progress / requirement.target */}
{node.requirement.label} · {pct}% {node.current_value}/{node.req_target} {reqLabel}{" "}
· {pct}%
</p> </p>
)} )}
</div> </div>
@ -804,8 +806,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
)} )}
</div> </div>
<div className="hc-map-link" onClick={handleViewAll}> <div className="hc-map-link" onClick={handleViewAll}>
<Map size={13} /> <Map size={13} /> View quest map
View quest map
</div> </div>
</div> </div>
</> </>
@ -813,7 +814,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
</div> </div>
{claimingNode && ( {claimingNode && (
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} /> <ChestOpenModal
node={claimingNode.node}
claimResult={claimResult}
onClose={handleChestClose}
/>
)} )}
</> </>
); );

View File

@ -0,0 +1,217 @@
import { useState } from "react";
import {
useInventoryStore,
getLiveEffects,
formatTimeLeft,
hasActiveEffect,
} from "../stores/useInventoryStore";
import { InventoryModal } from "./InventoryModal";
// ─── Styles ───────────────────────────────────────────────────────────────────
const BTN_STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&family=Cinzel:wght@700&display=swap');
/* ── Inventory trigger button ── */
.inv-btn {
position: relative;
display: inline-flex; align-items: center; gap: 0.38rem;
padding: 0.48rem 0.85rem;
background: rgba(255,255,255,0.05);
border: 1.5px solid rgba(255,255,255,0.1);
border-radius: 100px;
cursor: pointer;
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 900;
color: rgba(255,255,255,0.7);
transition: all 0.18s ease;
outline: none;
white-space: nowrap;
}
.inv-btn:hover {
background: rgba(255,255,255,0.09);
border-color: rgba(255,255,255,0.2);
color: white;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
.inv-btn:active { transform: translateY(0) scale(0.97); }
/* When active effects are running — gold glow */
.inv-btn.has-active {
border-color: rgba(251,191,36,0.45);
color: #fbbf24;
background: rgba(251,191,36,0.08);
animation: invBtnGlow 2.6s ease-in-out infinite;
}
@keyframes invBtnGlow {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 14px 3px rgba(251,191,36,0.2); }
}
.inv-btn.has-active:hover {
border-color: rgba(251,191,36,0.7);
background: rgba(251,191,36,0.14);
}
/* Badge dot */
.inv-btn-badge {
position: absolute; top: -4px; right: -4px;
width: 14px; height: 14px; border-radius: 50%;
background: #fbbf24;
border: 2px solid transparent; /* will be set to match parent bg via CSS var */
display: flex; align-items: center; justify-content: center;
font-family: 'Nunito', sans-serif;
font-size: 0.45rem; font-weight: 900; color: #1a0800;
animation: invBadgePop 1.8s ease-in-out infinite;
}
@keyframes invBadgePop {
0%,100%{ transform: scale(1); }
50% { transform: scale(1.15); }
}
/* ── Active Effect Banner (shown on other screens, e.g. pretest) ── */
.aeb-wrap {
display: flex; gap: 0.5rem; flex-wrap: wrap;
}
.aeb-pill {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.38rem 0.85rem;
border-radius: 100px;
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 900;
animation: aebPillIn 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
animation-delay: var(--aeb-delay, 0s);
}
@keyframes aebPillIn {
from { opacity:0; transform: scale(0.8) translateY(6px); }
to { opacity:1; transform: scale(1) translateY(0); }
}
/* Color variants per effect type */
.aeb-pill.xp_boost {
background: rgba(251,191,36,0.12);
border: 1.5px solid rgba(251,191,36,0.4);
color: #fbbf24;
}
.aeb-pill.streak_shield {
background: rgba(96,165,250,0.1);
border: 1.5px solid rgba(96,165,250,0.35);
color: #60a5fa;
}
.aeb-pill.coin_boost {
background: rgba(167,243,208,0.08);
border: 1.5px solid rgba(52,211,153,0.35);
color: #34d399;
}
.aeb-pill.default {
background: rgba(255,255,255,0.06);
border: 1.5px solid rgba(255,255,255,0.15);
color: rgba(255,255,255,0.7);
}
.aeb-pill-icon { font-size: 0.9rem; line-height:1; }
.aeb-pill-label { line-height:1; }
.aeb-pill-time {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.58rem; font-weight: 700;
opacity: 0.55; margin-left: 0.1rem;
}
`;
const ITEM_ICON: Record<string, string> = {
xp_boost: "⚡",
streak_shield: "🛡️",
title: "🏴‍☠️",
coin_boost: "🪙",
};
function itemIcon(effectType: string): string {
return ITEM_ICON[effectType] ?? "📦";
}
// ─── InventoryButton ──────────────────────────────────────────────────────────
/**
* Drop-in trigger button. Can be placed in any nav bar, header, or screen.
* Shows a gold glow + badge count when active effects are running.
*
* Usage:
* <InventoryButton />
* <InventoryButton label="Hold" />
*/
export const InventoryButton = ({}: {}) => {
const [open, setOpen] = useState(false);
const activeEffects = useInventoryStore((s) => s.activeEffects);
const liveEffects = getLiveEffects(activeEffects);
const hasActive = liveEffects.length > 0;
return (
<>
<style>{BTN_STYLES}</style>
<button
className={`inv-btn${hasActive ? " has-active" : ""}`}
onClick={() => setOpen(true)}
aria-label="Open inventory"
>
🎒
{hasActive && (
<span className="inv-btn-badge">{liveEffects.length}</span>
)}
</button>
{open && <InventoryModal onClose={() => setOpen(false)} />}
</>
);
};
// ─── ActiveEffectBanner ───────────────────────────────────────────────────────
/**
* Shows pills for each currently-active effect.
* Place wherever you want a contextual reminder (pretest screen, dashboard, etc.)
*
* Usage:
* <ActiveEffectBanner />
* <ActiveEffectBanner filter="xp_boost" /> ← only show a specific effect
*
* Example output on Pretest screen:
* ⚡ XP Boost ×2 · 1h 42m 🛡️ Streak Shield · 23m
*/
export const ActiveEffectBanner = ({
filter,
className,
}: {
filter?: string;
className?: string;
}) => {
const activeEffects = useInventoryStore((s) => s.activeEffects);
const live = getLiveEffects(activeEffects).filter(
(e) => !filter || e.item.effect_type === filter,
);
if (live.length === 0) return null;
return (
<>
<style>{BTN_STYLES}</style>
<div className={`aeb-wrap${className ? ` ${className}` : ""}`}>
{live.map((e, i) => (
<div
key={e.id}
className={`aeb-pill ${e.item.effect_type ?? "default"}`}
style={{ "--aeb-delay": `${i * 0.07}s` } as React.CSSProperties}
>
<span className="aeb-pill-icon">
{itemIcon(e.item.effect_type)}
</span>
<span className="aeb-pill-label">
{e.item.name}
{e.item.effect_type === "xp_boost" && e.item.effect_value
? ` ×${e.item.effect_value}`
: ""}
</span>
<span className="aeb-pill-time">
{formatTimeLeft(e.expires_at)}
</span>
</div>
))}
</div>
</>
);
};

View File

@ -0,0 +1,675 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { X } from "lucide-react";
import type { InventoryItem, ActiveEffect } from "../types/quest";
import {
useInventoryStore,
getLiveEffects,
formatTimeLeft,
} from "../stores/useInventoryStore";
import { useAuthStore } from "../stores/authStore";
import { api } from "../utils/api";
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* ══ OVERLAY ══ */
.inv-overlay {
position: fixed; inset: 0; z-index: 60;
background: rgba(2,5,15,0.78);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
display: flex; align-items: flex-end; justify-content: center;
animation: invFadeIn 0.2s ease both;
}
@keyframes invFadeIn { from{opacity:0} to{opacity:1} }
/* ══ SHEET ══ */
.inv-sheet {
width: 100%; max-width: 540px;
background: linear-gradient(180deg, #08111f 0%, #050d1a 100%);
border-radius: 28px 28px 0 0;
border-top: 1.5px solid rgba(251,191,36,0.25);
box-shadow:
0 -16px 60px rgba(0,0,0,0.7),
inset 0 1px 0 rgba(255,255,255,0.06);
overflow: hidden;
display: flex; flex-direction: column;
max-height: 88vh;
animation: invSlideUp 0.38s cubic-bezier(0.34,1.56,0.64,1) both;
position: relative;
}
@keyframes invSlideUp {
from { transform: translateY(100%); opacity:0; }
to { transform: translateY(0); opacity:1; }
}
/* Sea shimmer bg */
.inv-sheet::before {
content: '';
position: absolute; inset: 0; pointer-events: none; z-index: 0;
background:
repeating-linear-gradient(110deg, transparent 60%, rgba(56,189,248,0.015) 61%, transparent 62%),
repeating-linear-gradient(70deg, transparent 72%, rgba(56,189,248,0.01) 73%, transparent 74%);
background-size: 300% 300%, 240% 240%;
animation: invSeaSway 16s ease-in-out infinite alternate;
}
@keyframes invSeaSway {
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
/* Gold orb top-right */
.inv-sheet::after {
content: '';
position: absolute; top: -60px; right: -40px; z-index: 0;
width: 220px; height: 220px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.07), transparent 68%);
pointer-events: none;
}
/* ── Handle ── */
.inv-handle-row {
display: flex; justify-content: center;
padding: 0.75rem 0 0; flex-shrink: 0; position: relative; z-index: 2;
}
.inv-handle {
width: 40px; height: 4px; border-radius: 100px;
background: rgba(255,255,255,0.1);
}
/* ── Header ── */
.inv-header {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
padding: 0.85rem 1.3rem 0;
}
.inv-header-left { display: flex; flex-direction: column; gap: 0.1rem; }
.inv-eyebrow {
font-family: 'Cinzel', serif;
font-size: 0.5rem; font-weight: 700; letter-spacing: 0.22em;
text-transform: uppercase; color: rgba(251,191,36,0.55);
}
.inv-title {
font-family: 'Cinzel', serif;
font-size: 1.28rem; font-weight: 900; color: #fff;
letter-spacing: 0.03em;
text-shadow: 0 0 24px rgba(251,191,36,0.3);
}
.inv-close {
width: 32px; height: 32px; border-radius: 50%;
border: 1.5px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.15s;
flex-shrink: 0;
}
.inv-close:hover {
border-color: rgba(251,191,36,0.5);
background: rgba(251,191,36,0.1);
}
/* ── Active effects banner ── */
.inv-active-bar {
position: relative; z-index: 2;
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: none;
padding: 0.75rem 1.3rem 0;
}
.inv-active-bar::-webkit-scrollbar { display: none; }
.inv-active-pill {
display: flex; align-items: center; gap: 0.4rem;
flex-shrink: 0;
padding: 0.35rem 0.75rem;
border-radius: 100px;
border: 1.5px solid rgba(251,191,36,0.35);
background: rgba(251,191,36,0.08);
animation: invPillGlow 2.4s ease-in-out infinite;
}
@keyframes invPillGlow {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 12px 2px rgba(251,191,36,0.18); }
}
.inv-active-pill-icon { font-size: 0.9rem; }
.inv-active-pill-name {
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 900; color: #fbbf24;
}
.inv-active-pill-time {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.6rem; font-weight: 700;
color: rgba(251,191,36,0.5);
margin-left: 0.1rem;
}
/* ── Divider ── */
.inv-divider {
position: relative; z-index: 2;
height: 1px; margin: 0.85rem 1.3rem 0;
background: rgba(255,255,255,0.06);
}
.inv-section-label {
position: relative; z-index: 2;
padding: 0.7rem 1.3rem 0.35rem;
font-family: 'Cinzel', serif;
font-size: 0.48rem; font-weight: 700; letter-spacing: 0.2em;
text-transform: uppercase; color: rgba(255,255,255,0.25);
}
/* ── Scrollable item grid ── */
.inv-scroll {
position: relative; z-index: 2;
flex: 1; overflow-y: auto; scrollbar-width: none;
padding: 0 1.1rem calc(1.5rem + env(safe-area-inset-bottom));
}
.inv-scroll::-webkit-scrollbar { display: none; }
/* ── Empty state ── */
.inv-empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 0.6rem;
padding: 3rem 1rem;
font-family: 'Nunito', sans-serif;
font-size: 0.85rem; font-weight: 800;
color: rgba(255,255,255,0.25);
}
.inv-empty-icon { font-size: 2.5rem; opacity: 0.4; }
/* ── Loading skeleton ── */
.inv-skeleton-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
}
.inv-skeleton-card {
height: 140px; border-radius: 20px;
background: rgba(255,255,255,0.04);
animation: invSkel 1.6s ease-in-out infinite;
}
@keyframes invSkel {
0%,100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* ── Item grid ── */
.inv-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
}
/* ── Item card ── */
.inv-card {
border-radius: 20px; padding: 1rem;
border: 1.5px solid rgba(255,255,255,0.07);
background: rgba(255,255,255,0.03);
display: flex; flex-direction: column; gap: 0.6rem;
cursor: pointer; position: relative; overflow: hidden;
transition: border-color 0.2s, background 0.2s, transform 0.15s;
animation: invCardIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
animation-delay: var(--ci-delay, 0s);
}
@keyframes invCardIn {
from { opacity:0; transform: translateY(14px) scale(0.95); }
to { opacity:1; transform: translateY(0) scale(1); }
}
.inv-card:hover {
border-color: rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
transform: translateY(-2px);
}
.inv-card:active { transform: translateY(0) scale(0.98); }
/* Active card styling */
.inv-card.is-active {
border-color: rgba(251,191,36,0.4);
background: rgba(251,191,36,0.06);
}
.inv-card.is-active:hover {
border-color: rgba(251,191,36,0.6);
background: rgba(251,191,36,0.09);
}
/* Just-activated flash */
@keyframes invActivateFlash {
0% { background: rgba(251,191,36,0.25); border-color: rgba(251,191,36,0.8); }
100%{ background: rgba(251,191,36,0.06); border-color: rgba(251,191,36,0.4); }
}
.inv-card.just-activated {
animation: invActivateFlash 0.9s ease forwards;
}
/* Card shimmer overlay */
.inv-card-sheen {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.04) 50%, transparent 70%);
transform: translateX(-100%);
transition: transform 0.5s ease;
}
.inv-card:hover .inv-card-sheen { transform: translateX(100%); }
/* Icon area */
.inv-card-icon-wrap {
width: 44px; height: 44px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0; position: relative;
}
.inv-card.is-active .inv-card-icon-wrap {
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.3);
}
.inv-card-active-dot {
position: absolute; top: -3px; right: -3px;
width: 10px; height: 10px; border-radius: 50%;
background: #fbbf24;
border: 2px solid #08111f;
animation: invDotPulse 2s ease-in-out infinite;
}
@keyframes invDotPulse {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0.6); }
50% { box-shadow: 0 0 0 5px rgba(251,191,36,0); }
}
/* Card text */
.inv-card-name {
font-family: 'Nunito', sans-serif;
font-size: 0.82rem; font-weight: 900; color: #fff;
line-height: 1.2;
}
.inv-card.is-active .inv-card-name { color: #fbbf24; }
.inv-card-desc {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.63rem; font-weight: 600;
color: rgba(255,255,255,0.38); line-height: 1.4;
flex: 1;
}
/* Qty + type row */
.inv-card-meta {
display: flex; align-items: center; justify-content: space-between;
gap: 0.4rem; margin-top: auto;
}
.inv-card-qty {
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900;
color: rgba(255,255,255,0.3);
background: rgba(255,255,255,0.05);
border-radius: 100px; padding: 0.15rem 0.45rem;
}
.inv-card-type {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.56rem; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase;
color: rgba(255,255,255,0.22);
}
/* Activate button */
.inv-activate-btn {
width: 100%;
padding: 0.48rem;
border-radius: 10px; border: none; cursor: pointer;
font-family: 'Nunito', sans-serif;
font-size: 0.7rem; font-weight: 900;
transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
}
.inv-activate-btn.idle {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.6);
}
.inv-activate-btn.idle:hover {
background: rgba(255,255,255,0.12);
color: white;
}
.inv-activate-btn.activating {
background: rgba(251,191,36,0.1);
border: 1px solid rgba(251,191,36,0.25);
color: rgba(251,191,36,0.6);
cursor: not-allowed;
animation: invSpinLabel 0.4s ease infinite alternate;
}
@keyframes invSpinLabel { from{opacity:0.5} to{opacity:1} }
.inv-activate-btn.active-state {
background: rgba(251,191,36,0.12);
border: 1px solid rgba(251,191,36,0.3);
color: #fbbf24;
cursor: default;
}
.inv-activate-btn.success-flash {
background: rgba(74,222,128,0.18);
border: 1px solid rgba(74,222,128,0.4);
color: #4ade80;
animation: invSuccessScale 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes invSuccessScale {
from { transform: scale(0.94); }
to { transform: scale(1); }
}
.inv-activate-btn:disabled { pointer-events: none; }
/* Time remaining on active button */
.inv-active-time {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.55rem; font-weight: 700;
color: rgba(251,191,36,0.5);
}
/* ── Toast ── */
.inv-toast {
position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom));
left: 50%; transform: translateX(-50%);
z-index: 90;
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 1.2rem;
background: linear-gradient(135deg, #1a3a1a, #0d2010);
border: 1.5px solid rgba(74,222,128,0.45);
border-radius: 100px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 20px rgba(74,222,128,0.12);
font-family: 'Nunito', sans-serif;
font-size: 0.8rem; font-weight: 900; color: #4ade80;
white-space: nowrap;
animation: invToastIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both,
invToastOut 0.3s 2.7s ease forwards;
}
@keyframes invToastIn { from{opacity:0; transform:translateX(-50%) translateY(20px) scale(0.9)} to{opacity:1; transform:translateX(-50%) translateY(0) scale(1)} }
@keyframes invToastOut { from{opacity:1} to{opacity:0; transform:translateX(-50%) translateY(8px)} }
`;
// ─── Item metadata ─────────────────────────────────────────────────────────────
const ITEM_ICON: Record<string, string> = {
xp_boost: "⚡",
streak_shield: "🛡️",
title: "🏴‍☠️",
coin_boost: "🪙",
};
const ITEM_ICON_DEFAULT = "📦";
function itemIcon(effectType: string): string {
return ITEM_ICON[effectType] ?? ITEM_ICON_DEFAULT;
}
// ─── Check if an item is currently active ─────────────────────────────────────
function isItemActive(
item: InventoryItem,
activeEffects: ActiveEffect[],
): ActiveEffect | null {
const now = Date.now();
return (
activeEffects.find(
(e) =>
e.item.id === item.item.id && new Date(e.expires_at).getTime() > now,
) ?? null
);
}
// ─── Item card ────────────────────────────────────────────────────────────────
const ItemCard = ({
inv,
activeEffects,
activatingId,
lastActivatedId,
onActivate,
index,
}: {
inv: InventoryItem;
activeEffects: ActiveEffect[];
activatingId: string | null;
lastActivatedId: string | null;
onActivate: (id: string) => void;
index: number;
}) => {
const activeEffect = isItemActive(inv, activeEffects);
const isActive = !!activeEffect;
const isActivating = activatingId === inv.id;
const justActivated = lastActivatedId === inv.id;
let btnState: "idle" | "activating" | "active-state" | "success-flash" =
"idle";
if (justActivated) btnState = "success-flash";
else if (isActivating) btnState = "activating";
else if (isActive) btnState = "active-state";
let btnLabel = "Use Item";
if (btnState === "activating") btnLabel = "Activating…";
else if (btnState === "success-flash") btnLabel = "✓ Activated!";
else if (btnState === "active-state") btnLabel = "✓ Active";
return (
<div
className={`inv-card${isActive ? " is-active" : ""}${justActivated ? " just-activated" : ""}`}
style={{ "--ci-delay": `${index * 0.045}s` } as React.CSSProperties}
>
<div className="inv-card-sheen" />
{/* Icon */}
<div className="inv-card-icon-wrap">
{itemIcon(inv.item.effect_type)}
{isActive && <div className="inv-card-active-dot" />}
</div>
{/* Name + description */}
<p className="inv-card-name">{inv.item.name}</p>
<p className="inv-card-desc">{inv.item.description}</p>
{/* Qty + type */}
<div className="inv-card-meta">
<span className="inv-card-qty">×{inv.quantity}</span>
<span className="inv-card-type">
{inv.item.type.replace(/_/g, " ")}
</span>
</div>
{/* Time remaining if active */}
{isActive && activeEffect && (
<div className="inv-active-time">
{formatTimeLeft(activeEffect.expires_at)} remaining
</div>
)}
{/* Activate button */}
<button
className={`inv-activate-btn ${btnState}`}
onClick={() => !isActive && !isActivating && onActivate(inv.id)}
disabled={isActive || isActivating}
>
{btnLabel}
</button>
</div>
);
};
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
onClose: () => void;
}
export const InventoryModal = ({ onClose }: Props) => {
const token = useAuthStore((s) => s.token);
const items = useInventoryStore((s) => s.items);
const activeEffects = useInventoryStore((s) => s.activeEffects);
const loading = useInventoryStore((s) => s.loading);
const activatingId = useInventoryStore((s) => s.activatingId);
const lastActivatedId = useInventoryStore((s) => s.lastActivatedId);
const error = useInventoryStore((s) => s.error);
const syncFromAPI = useInventoryStore((s) => s.syncFromAPI);
const setLoading = useInventoryStore((s) => s.setLoading);
const activateItemOptimistic = useInventoryStore(
(s) => s.activateItemOptimistic,
);
const activateItemSuccess = useInventoryStore((s) => s.activateItemSuccess);
const activateItemError = useInventoryStore((s) => s.activateItemError);
const clearLastActivated = useInventoryStore((s) => s.clearLastActivated);
const [showToast, setShowToast] = useState(false);
const [toastMsg, setToastMsg] = useState("");
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Fetch on open ──────────────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
let cancelled = false;
const fetchInv = async () => {
setLoading(true);
try {
const inv = await api.fetchUserInventory(token);
if (!cancelled) syncFromAPI(inv);
} catch (e) {
// Silently fail — cached data stays visible
} finally {
if (!cancelled) setLoading(false);
}
};
fetchInv();
return () => {
cancelled = true;
};
}, [token]);
// ── Activate ──────────────────────────────────────────────────────────────
const handleActivate = useCallback(
async (itemId: string) => {
if (!token) return;
activateItemOptimistic(itemId);
try {
const updatedInv = await api.activateItem(token, itemId);
activateItemSuccess(updatedInv, itemId);
// Find item name for toast
const name = items.find((i) => i.id === itemId)?.item.name ?? "Item";
setToastMsg(
`${itemIcon(items.find((i) => i.id === itemId)?.item.effect_type ?? "")} ${name} activated!`,
);
setShowToast(true);
// Auto-clear success state + toast
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => {
setShowToast(false);
clearLastActivated();
}, 3000);
} catch (e) {
activateItemError(
itemId,
e instanceof Error ? e.message : "Failed to activate",
);
}
},
[token, items],
);
// Cleanup timer on unmount
useEffect(
() => () => {
if (toastTimer.current) clearTimeout(toastTimer.current);
},
[],
);
const liveEffects = getLiveEffects(activeEffects);
return (
<>
<style>{STYLES}</style>
<div className="inv-overlay" onClick={onClose}>
<div className="inv-sheet" onClick={(e) => e.stopPropagation()}>
{/* Handle */}
<div className="inv-handle-row">
<div className="inv-handle" />
</div>
{/* Header */}
<div className="inv-header">
<div className="inv-header-left">
<span className="inv-eyebrow"> Pirate's Hold</span>
<h2 className="inv-title">Inventory</h2>
</div>
<button className="inv-close" onClick={onClose}>
<X size={14} color="rgba(255,255,255,0.5)" />
</button>
</div>
{/* Active effects bar */}
{liveEffects.length > 0 && (
<div className="inv-active-bar">
{liveEffects.map((e) => (
<div key={e.id} className="inv-active-pill">
<span className="inv-active-pill-icon">
{itemIcon(e.item.effect_type)}
</span>
<span className="inv-active-pill-name">{e.item.name}</span>
<span className="inv-active-pill-time">
{formatTimeLeft(e.expires_at)}
</span>
</div>
))}
</div>
)}
<div className="inv-divider" />
<p className="inv-section-label">
{items.length > 0
? `${items.length} item${items.length !== 1 ? "s" : ""} in your hold`
: "Your hold"}
</p>
{/* Scroll area */}
<div className="inv-scroll">
{loading && items.length === 0 ? (
<div className="inv-skeleton-grid">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="inv-skeleton-card"
style={{ animationDelay: `${i * 0.1}s` }}
/>
))}
</div>
) : items.length === 0 ? (
<div className="inv-empty">
<span className="inv-empty-icon">🏴‍☠️</span>
<p>Your hold is empty — claim quests to earn items!</p>
</div>
) : (
<div className="inv-grid">
{items.map((inv, i) => (
<ItemCard
key={inv.id}
inv={inv}
activeEffects={activeEffects}
activatingId={activatingId}
lastActivatedId={lastActivatedId}
onActivate={handleActivate}
index={i}
/>
))}
</div>
)}
{/* Error inline */}
{error && (
<p
style={{
textAlign: "center",
padding: "0.5rem",
fontFamily: "'Nunito',sans-serif",
fontSize: "0.72rem",
color: "#ef4444",
fontWeight: 800,
}}
>
⚠️ {error}
</p>
)}
</div>
</div>
</div>
{/* Success toast */}
{showToast && <div className="inv-toast">{toastMsg}</div>}
</>
);
};

View File

@ -1,6 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X, Lock } from "lucide-react"; import { X, Lock } from "lucide-react";
import type { QuestNode } from "../types/quest"; 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 ─────────────────────────────────────────────────────────────────── // ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = ` const STYLES = `
@ -30,11 +54,9 @@ const STYLES = `
} }
@keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} } @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-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-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); }
/* Close btn */
.qnm-close { .qnm-close {
position:absolute; top:0.9rem; right:1.1rem; z-index:10; position:absolute; top:0.9rem; right:1.1rem; z-index:10;
width:30px; height:30px; border-radius:50%; width:30px; height:30px; border-radius:50%;
@ -50,8 +72,6 @@ const STYLES = `
height: 200px; overflow: hidden; height: 200px; overflow: hidden;
background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%); background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%);
} }
/* Sea waves */
.qnm-sea { .qnm-sea {
position:absolute; bottom:0; left:0; right:0; height:52px; position:absolute; bottom:0; left:0; right:0; height:52px;
background: var(--sea-col); overflow:hidden; background: var(--sea-col); overflow:hidden;
@ -69,12 +89,9 @@ const STYLES = `
50% { transform: translateX(15%) scaleY(1.08);} 50% { transform: translateX(15%) scaleY(1.08);}
100%{ transform: translateX(0) scaleY(1); } 100%{ transform: translateX(0) scaleY(1); }
} }
/* Floating clouds */
.qnm-cloud { .qnm-cloud {
position:absolute; border-radius:50px; position:absolute; border-radius:50px;
background: rgba(255,255,255,0.18); background: rgba(255,255,255,0.18); filter: blur(4px);
filter: blur(4px);
animation: qnmDrift var(--cdur,18s) linear infinite; animation: qnmDrift var(--cdur,18s) linear infinite;
} }
@keyframes qnmDrift { @keyframes qnmDrift {
@ -83,11 +100,8 @@ const STYLES = `
90% { opacity:1; } 90% { opacity:1; }
100%{ transform: translateX(calc(100vw + 120px)); opacity:0; } 100%{ transform: translateX(calc(100vw + 120px)); opacity:0; }
} }
/* ── The 3D island container ── */
.qnm-island-3d-wrap { .qnm-island-3d-wrap {
position: absolute; position: absolute; left: 50%; bottom: 40px;
left: 50%; bottom: 40px;
transform: translateX(-50%); transform: translateX(-50%);
perspective: 420px; perspective: 420px;
width: 220px; height: 140px; width: 220px; height: 140px;
@ -102,16 +116,12 @@ const STYLES = `
0% { transform: rotateX(22deg) rotateY(0deg); } 0% { transform: rotateX(22deg) rotateY(0deg); }
100% { transform: rotateX(22deg) rotateY(360deg); } 100% { transform: rotateX(22deg) rotateY(360deg); }
} }
.qnm-il {
/* Island layers — stacked in 3D */
.qnm-il { /* island layer base class */
position: absolute; left: 50%; bottom: 0; position: absolute; left: 50%; bottom: 0;
transform-origin: bottom center; transform-origin: bottom center;
border-radius: 50%; border-radius: 50%;
transform-style: preserve-3d; transform-style: preserve-3d;
} }
/* Water base disc */
.qnm-il-water { .qnm-il-water {
width: 200px; height: 44px; margin-left: -100px; width: 200px; height: 44px; margin-left: -100px;
background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col)); background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col));
@ -120,12 +130,7 @@ const STYLES = `
box-shadow: 0 0 40px var(--sea-col); box-shadow: 0 0 40px var(--sea-col);
animation: qnmWaterShimmer 3s ease-in-out infinite; animation: qnmWaterShimmer 3s ease-in-out infinite;
} }
@keyframes qnmWaterShimmer { @keyframes qnmWaterShimmer { 0%,100%{ opacity:1; } 50%{ opacity:0.82; } }
0%,100%{ opacity:1; }
50% { opacity:0.82; }
}
/* Ripple rings on water */
.qnm-ripple { .qnm-ripple {
position:absolute; left:50%; top:50%; position:absolute; left:50%; top:50%;
border-radius:50%; border:1.5px solid rgba(255,255,255,0.25); border-radius:50%; border:1.5px solid rgba(255,255,255,0.25);
@ -136,8 +141,6 @@ const STYLES = `
0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; } 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; } 100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; }
} }
/* Island ground */
.qnm-il-ground { .qnm-il-ground {
width: 160px; height: 36px; margin-left: -80px; width: 160px; height: 36px; margin-left: -80px;
background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo)); background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo));
@ -145,8 +148,6 @@ const STYLES = `
transform: translateZ(14px); transform: translateZ(14px);
box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25); 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 { .qnm-il-side {
width: 158px; height: 22px; margin-left: -79px; width: 158px; height: 22px; margin-left: -79px;
bottom: -12px; bottom: -12px;
@ -154,8 +155,6 @@ const STYLES = `
clip-path: ellipse(79px 100% at 50% 0%); clip-path: ellipse(79px 100% at 50% 0%);
transform: translateZ(8px) rotateX(-8deg); transform: translateZ(8px) rotateX(-8deg);
} }
/* Peak */
.qnm-il-peak { .qnm-il-peak {
width: 80px; height: 60px; margin-left: -40px; width: 80px; height: 60px; margin-left: -40px;
bottom: 26px; bottom: 26px;
@ -169,8 +168,6 @@ const STYLES = `
0%,100%{ transform: translateZ(26px) translateY(0); } 0%,100%{ transform: translateZ(26px) translateY(0); }
50% { transform: translateZ(26px) translateY(-4px); } 50% { transform: translateZ(26px) translateY(-4px); }
} }
/* Floating decoration layer (trees, cactus, cloud orb, etc.) */
.qnm-il-deco { .qnm-il-deco {
position: absolute; bottom: 56px; left: 50%; position: absolute; bottom: 56px; left: 50%;
transform: translateZ(42px); transform: translateZ(42px);
@ -181,16 +178,8 @@ const STYLES = `
50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); } 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-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); }
/* Flag pole on active */ .qnm-flag-pole { width:2px; height:26px; background:#7c4a1e; border-radius:2px; }
.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 { .qnm-flag-cloth {
position:absolute; top:2px; left:2px; position:absolute; top:2px; left:2px;
width:16px; height:11px; width:16px; height:11px;
@ -198,12 +187,7 @@ const STYLES = `
animation: qnmFlagWave 1.2s ease-in-out infinite; animation: qnmFlagWave 1.2s ease-in-out infinite;
transform-origin:left center; transform-origin:left center;
} }
@keyframes qnmFlagWave { @keyframes qnmFlagWave { 0%,100%{ transform:skewY(0deg); } 50%{ transform:skewY(-10deg); } }
0%,100%{ transform:skewY(0deg); }
50% { transform:skewY(-10deg); }
}
/* Stars / sparkles above completed island */
.qnm-star { .qnm-star {
position:absolute; font-size:1rem; position:absolute; font-size:1rem;
animation: qnmStarPop var(--sdur,2s) ease-in-out infinite; animation: qnmStarPop var(--sdur,2s) ease-in-out infinite;
@ -221,8 +205,6 @@ const STYLES = `
padding:1.1rem 1.25rem 0.5rem; padding:1.1rem 1.25rem 0.5rem;
} }
.qnm-body::-webkit-scrollbar { display:none; } .qnm-body::-webkit-scrollbar { display:none; }
/* Title block */
.qnm-title-block { position:relative; } .qnm-title-block { position:relative; }
.qnm-arc-tag { .qnm-arc-tag {
display:inline-flex; align-items:center; gap:0.3rem; display:inline-flex; align-items:center; gap:0.3rem;
@ -240,8 +222,6 @@ const STYLES = `
font-family:'Nunito Sans',sans-serif; font-family:'Nunito Sans',sans-serif;
font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38); font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38);
} }
/* Flavour quote */
.qnm-flavour { .qnm-flavour {
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07); background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
border-left:3px solid var(--ac); border-left:3px solid var(--ac);
@ -253,8 +233,6 @@ const STYLES = `
font-size:0.82rem; color:rgba(255,255,255,0.55); font-size:0.82rem; color:rgba(255,255,255,0.55);
font-style:italic; line-height:1.6; font-style:italic; line-height:1.6;
} }
/* Objective card */
.qnm-obj-card { .qnm-obj-card {
background:rgba(255,255,255,0.04); background:rgba(255,255,255,0.04);
border:1px solid rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.08);
@ -271,9 +249,7 @@ const STYLES = `
font-family:'Nunito',sans-serif; font-family:'Nunito',sans-serif;
font-size:0.78rem; font-weight:900; color:var(--ac); font-size:0.78rem; font-weight:900; color:var(--ac);
} }
.qnm-obj-row { .qnm-obj-row { display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem; }
display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem;
}
.qnm-obj-icon { .qnm-obj-icon {
width:38px; height:38px; border-radius:12px; flex-shrink:0; 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); background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08);
@ -287,8 +263,6 @@ const STYLES = `
font-family:'Nunito Sans',sans-serif; font-family:'Nunito Sans',sans-serif;
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem; font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem;
} }
/* Progress bar */
.qnm-bar-track { .qnm-bar-track {
height:9px; background:rgba(255,255,255,0.07); height:9px; background:rgba(255,255,255,0.07);
border-radius:100px; overflow:hidden; margin-bottom:0.3rem; border-radius:100px; overflow:hidden; margin-bottom:0.3rem;
@ -305,21 +279,16 @@ const STYLES = `
font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28); font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28);
} }
.qnm-bar-nums span:first-child { color:var(--ac); } .qnm-bar-nums span:first-child { color:var(--ac); }
/* ── HOW TO COMPLETE section ── */
.qnm-howto-label { .qnm-howto-label {
font-size:0.58rem; font-weight:800; letter-spacing:0.14em; font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
text-transform:uppercase; color:rgba(255,255,255,0.3); text-transform:uppercase; color:rgba(255,255,255,0.3);
margin-bottom:0.55rem; margin-top:0.3rem; margin-bottom:0.55rem; margin-top:0.3rem;
} }
.qnm-howto-badges { .qnm-howto-badges { display:flex; flex-wrap:wrap; gap:0.4rem; }
display:flex; flex-wrap:wrap; gap:0.4rem;
}
.qnm-howto-badge { .qnm-howto-badge {
display:flex; align-items:center; gap:0.3rem; display:flex; align-items:center; gap:0.3rem;
padding:0.38rem 0.75rem; padding:0.38rem 0.75rem;
background:rgba(255,255,255,0.06); background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1);
border:1px solid rgba(255,255,255,0.1);
border-radius:100px; border-radius:100px;
font-family:'Nunito',sans-serif; font-family:'Nunito',sans-serif;
font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7); font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7);
@ -332,19 +301,14 @@ const STYLES = `
to { opacity:1; transform:scale(1) translateY(0); } to { opacity:1; transform:scale(1) translateY(0); }
} }
.qnm-howto-badge:hover { .qnm-howto-badge:hover {
background:rgba(255,255,255,0.1); background:rgba(255,255,255,0.1); border-color:rgba(255,255,255,0.2);
border-color:rgba(255,255,255,0.2); color:white; transform:translateY(-1px);
color:white;
transform:translateY(-1px);
} }
/* Highlight badge = accent coloured */
.qnm-howto-badge.hi { .qnm-howto-badge.hi {
background:color-mix(in srgb, var(--ac) 18%, transparent); background:color-mix(in srgb, var(--ac) 18%, transparent);
border-color:color-mix(in srgb, var(--ac) 45%, transparent); border-color:color-mix(in srgb, var(--ac) 45%, transparent);
color:var(--ac); color:var(--ac);
} }
/* Locked banner */
.qnm-locked-banner { .qnm-locked-banner {
display:flex; align-items:center; gap:0.7rem; display:flex; align-items:center; gap:0.7rem;
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07); background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
@ -362,11 +326,8 @@ const STYLES = `
font-family:'Nunito Sans',sans-serif; font-family:'Nunito Sans',sans-serif;
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem; font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem;
} }
/* Reward card */
.qnm-reward-card { .qnm-reward-card {
background:rgba(251,191,36,0.07); background:rgba(251,191,36,0.07); border:1px solid rgba(251,191,36,0.22);
border:1px solid rgba(251,191,36,0.22);
border-radius:18px; padding:0.9rem 1rem; border-radius:18px; padding:0.9rem 1rem;
} }
.qnm-reward-label { .qnm-reward-label {
@ -409,71 +370,12 @@ const STYLES = `
} }
`; `;
// ─── Per-arc terrain themes ─────────────────────────────────────────────────── // ─── How-to badges ────────────────────────────────────────────────────────────
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 { interface Badge {
emoji: string; emoji: string;
label: string; label: string;
highlight?: boolean; highlight?: boolean;
} }
const HOW_TO: Record<string, { title: string; badges: Badge[] }> = { const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
questions: { questions: {
title: "How to complete this", title: "How to complete this",
@ -540,12 +442,7 @@ const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
}, },
}; };
// ─── Island shape configs (mirrors the 6 clip-path shapes in QuestMap) ──────── // ─── Island shape configs (mirrors QuestMap SHAPES[0..5]) ─────────────────────
// 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 { interface ShapeConfig {
groundClip: string; groundClip: string;
peakClip: string; peakClip: string;
@ -554,12 +451,9 @@ interface ShapeConfig {
groundH: number; groundH: number;
peakW: number; peakW: number;
peakH: number; peakH: number;
peakBottom: number; // translateZ bottom offset in px peakBottom: number;
} }
// These correspond 1-to-1 with SHAPES[0..5] in QuestMap.tsx
const ISLAND_SHAPES: ShapeConfig[] = [ const ISLAND_SHAPES: ShapeConfig[] = [
// 0: fat round atoll
{ {
groundClip: "ellipse(50% 50% at 50% 50%)", groundClip: "ellipse(50% 50% at 50% 50%)",
peakClip: "ellipse(50% 50% at 50% 55%)", peakClip: "ellipse(50% 50% at 50% 55%)",
@ -570,7 +464,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 38, peakH: 38,
peakBottom: 26, peakBottom: 26,
}, },
// 1: tall mountain — narrow diamond ground, sharp triangular peak
{ {
groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)", groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)",
peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)", peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)",
@ -581,7 +474,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 72, peakH: 72,
peakBottom: 24, peakBottom: 24,
}, },
// 2: wide flat shoal — extra-wide squashed ellipse, low dome
{ {
groundClip: "ellipse(50% 40% at 50% 58%)", groundClip: "ellipse(50% 40% at 50% 58%)",
peakClip: "ellipse(50% 38% at 50% 60%)", peakClip: "ellipse(50% 38% at 50% 60%)",
@ -592,7 +484,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 28, peakH: 28,
peakBottom: 22, peakBottom: 22,
}, },
// 3: jagged rocky reef — star-burst polygon
{ {
groundClip: groundClip:
"polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)", "polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)",
@ -605,7 +496,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 66, peakH: 66,
peakBottom: 24, peakBottom: 24,
}, },
// 4: crescent — lopsided asymmetric bean
{ {
groundClip: 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')", "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')",
@ -617,7 +507,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 58, peakH: 58,
peakBottom: 22, peakBottom: 22,
}, },
// 5: teardrop/pear — narrow top, wide rounded base
{ {
groundClip: 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')", "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')",
@ -632,29 +521,104 @@ const ISLAND_SHAPES: ShapeConfig[] = [
}, },
]; ];
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Terrain type (mirrors ArcTheme.terrain from QuestMap) ────────────────────
const reqIcon = (type: string): string => interface StageTerrain {
({ skyTop: string;
questions: "❓", skyBot: string;
accuracy: "🎯", seaCol: string;
streak: "🔥", seaHi: string;
sessions: "📚", terrHi: string;
topics: "🗺️", terrMid: string;
xp: "⚡", terrLo: string;
leaderboard: "🏆", peakHi: string;
})[type] ?? "⭐"; 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 ────────────────────────────────────────────────────────── // ─── 3D Island Stage ──────────────────────────────────────────────────────────
const IslandStage = ({ const IslandStage = ({
arc,
arcId, arcId,
status, status,
nodeIndex, nodeIndex,
}: { }: {
arc: QuestArc;
arcId: string; arcId: string;
status: QuestNode["status"]; status: string;
nodeIndex: number; nodeIndex: number;
}) => { }) => {
const t = TERRAIN[arcId] ?? DEFAULT_TERRAIN; const t = terrainFromTheme(arcId, arc);
const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length]; const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length];
const isCompleted = status === "completed"; const isCompleted = status === "completed";
@ -715,7 +679,7 @@ const IslandStage = ({
/> />
</div> </div>
{/* Ripple rings on water surface */} {/* Ripple rings */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@ -737,15 +701,9 @@ const IslandStage = ({
> >
<div <div
className="qnm-island-3d" className="qnm-island-3d"
style={{ style={{ animationPlayState: isLocked ? "paused" : "running" }}
// Pause rotation when locked
animationPlayState: isLocked ? "paused" : "running",
}}
> >
{/* Water base */}
<div className="qnm-il qnm-il-water" /> <div className="qnm-il qnm-il-water" />
{/* Island side face */}
<div <div
className="qnm-il qnm-il-side" className="qnm-il qnm-il-side"
style={{ style={{
@ -754,8 +712,6 @@ const IslandStage = ({
clipPath: shp.sideClip, clipPath: shp.sideClip,
}} }}
/> />
{/* Island ground — shaped to match QuestMap */}
<div <div
className="qnm-il qnm-il-ground" className="qnm-il qnm-il-ground"
style={{ style={{
@ -767,7 +723,6 @@ const IslandStage = ({
}} }}
/> />
{/* Peak / hill — shaped to match QuestMap */}
{!isLocked && ( {!isLocked && (
<div <div
className="qnm-il qnm-il-peak" className="qnm-il qnm-il-peak"
@ -796,15 +751,12 @@ const IslandStage = ({
</div> </div>
))} ))}
{/* Pirate flag on active */}
{isActive && ( {isActive && (
<div className="qnm-il-flag"> <div className="qnm-il-flag">
<div className="qnm-flag-pole" /> <div className="qnm-flag-pole" />
<div className="qnm-flag-cloth" /> <div className="qnm-flag-cloth" />
</div> </div>
)} )}
{/* Chest bouncing on claimable */}
{isClaimable && ( {isClaimable && (
<div className="qnm-il-deco" style={{ marginLeft: "-12px" }}> <div className="qnm-il-deco" style={{ marginLeft: "-12px" }}>
<span <span
@ -818,8 +770,6 @@ const IslandStage = ({
</span> </span>
</div> </div>
)} )}
{/* Lock icon on locked */}
{isLocked && ( {isLocked && (
<div <div
style={{ style={{
@ -838,9 +788,8 @@ const IslandStage = ({
</div> </div>
{/* Sparkles for completed */} {/* Sparkles for completed */}
{isCompleted && ( {isCompleted &&
<> [
{[
{ left: "30%", top: "18%", sdur: "2s", sdel: "0s" }, { left: "30%", top: "18%", sdur: "2s", sdel: "0s" },
{ left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" }, { left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" },
{ left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" }, { left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" },
@ -861,8 +810,6 @@ const IslandStage = ({
</span> </span>
))} ))}
</>
)}
{/* Lock overlay tint */} {/* Lock overlay tint */}
{isLocked && ( {isLocked && (
@ -886,6 +833,7 @@ const IslandStage = ({
// ─── Main component ─────────────────────────────────────────────────────────── // ─── Main component ───────────────────────────────────────────────────────────
interface Props { interface Props {
node: QuestNode; node: QuestNode;
arc: QuestArc; // full arc object needed for theme generation
arcAccent: string; arcAccent: string;
arcDark: string; arcDark: string;
arcId?: string; arcId?: string;
@ -896,6 +844,7 @@ interface Props {
export const QuestNodeModal = ({ export const QuestNodeModal = ({
node, node,
arc,
arcAccent, arcAccent,
arcDark, arcDark,
arcId = "east_blue", arcId = "east_blue",
@ -908,15 +857,19 @@ export const QuestNodeModal = ({
setMounted(true); setMounted(true);
}, []); }, []);
// ── New field names ──────────────────────────────────────────────────────
const progress = Math.min( const progress = Math.min(
100, 100,
Math.round((node.progress / node.requirement.target) * 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 isClaimable = node.status === "claimable";
const isLocked = node.status === "locked"; const isLocked = node.status === "locked";
const isCompleted = node.status === "completed"; const isCompleted = node.status === "completed";
const isActive = node.status === "active"; const isActive = node.status === "active";
const howTo = HOW_TO[node.requirement.type];
return ( return (
<div <div
@ -934,24 +887,32 @@ export const QuestNodeModal = ({
<X size={13} color="rgba(255,255,255,0.5)" /> <X size={13} color="rgba(255,255,255,0.5)" />
</button> </button>
{/* 3D island stage */} {/* 3D island stage — now receives full arc for theme generation */}
<IslandStage arcId={arcId} status={node.status} nodeIndex={nodeIndex} /> <IslandStage
arc={arc}
arcId={arcId}
status={node.status}
nodeIndex={nodeIndex}
/>
{/* Scrollable content */} {/* Scrollable content */}
<div className="qnm-body"> <div className="qnm-body">
{/* Title */} {/* Title block */}
<div className="qnm-title-block"> <div className="qnm-title-block">
<div className="qnm-arc-tag"> {/* req_type replaces node.requirement.type */}
{reqIcon(node.requirement.type)} Quest <div className="qnm-arc-tag">{reqIcon(node.req_type)} Quest</div>
</div> {/* node.name replaces node.title */}
<h2 className="qnm-quest-title">{node.title}</h2> <h2 className="qnm-quest-title">{node.name ?? "—"}</h2>
<p className="qnm-island-name">📍 {node.islandName}</p> {/* node.islandName removed — reuse node.name as location label */}
<p className="qnm-island-name">📍 {node.name ?? "—"}</p>
</div> </div>
{/* Flavour */} {/* Flavour — node.description replaces node.flavourText */}
{node.description && (
<div className="qnm-flavour"> <div className="qnm-flavour">
<p className="qnm-flavour-text">{node.flavourText}</p> <p className="qnm-flavour-text">{node.description}</p>
</div> </div>
)}
{/* Objective */} {/* Objective */}
<div className="qnm-obj-card"> <div className="qnm-obj-card">
@ -964,19 +925,18 @@ export const QuestNodeModal = ({
)} )}
</div> </div>
<div className="qnm-obj-row"> <div className="qnm-obj-row">
<div className="qnm-obj-icon"> <div className="qnm-obj-icon">{reqIcon(node.req_type)}</div>
{reqIcon(node.requirement.type)}
</div>
<div> <div>
{/* req_target + derived label replace node.requirement.target/label */}
<p className="qnm-obj-text"> <p className="qnm-obj-text">
{node.requirement.target} {node.requirement.label} {node.req_target} {reqLabel}
</p> </p>
<p className="qnm-obj-sub"> <p className="qnm-obj-sub">
{isCompleted {isCompleted
? "✅ Completed — treasure claimed!" ? "✅ Completed — treasure claimed!"
: isLocked : isLocked
? "🔒 Complete previous quests first" ? "🔒 Complete previous quests first"
: `${node.progress} / ${node.requirement.target} done`} : `${node.current_value} / ${node.req_target} done`}
</p> </p>
</div> </div>
</div> </div>
@ -990,14 +950,15 @@ export const QuestNodeModal = ({
style={{ width: mounted ? `${progress}%` : "0%" }} style={{ width: mounted ? `${progress}%` : "0%" }}
/> />
</div> </div>
{/* current_value / req_target replace old progress / requirement.target */}
<div className="qnm-bar-nums"> <div className="qnm-bar-nums">
<span>{node.progress}</span> <span>{node.current_value}</span>
<span>{node.requirement.target}</span> <span>{node.req_target}</span>
</div> </div>
</> </>
)} )}
{/* How-to badges — show when active or claimable */} {/* How-to badges */}
{(isActive || isClaimable) && howTo && ( {(isActive || isClaimable) && howTo && (
<> <>
<p className="qnm-howto-label" style={{ marginTop: "0.75rem" }}> <p className="qnm-howto-label" style={{ marginTop: "0.75rem" }}>
@ -1036,19 +997,26 @@ export const QuestNodeModal = ({
</div> </div>
)} )}
{/* Reward */} {/* Reward — sources from flat node reward fields */}
<div className="qnm-reward-card"> <div className="qnm-reward-card">
<p className="qnm-reward-label">📦 Treasure Chest</p> <p className="qnm-reward-label">📦 Treasure Chest</p>
<div className="qnm-reward-row"> <div className="qnm-reward-row">
<div className="qnm-reward-pill"> +{node.reward.xp} XP</div> {/* reward_coins replaces node.reward.xp */}
{node.reward.title && ( {node.reward_coins > 0 && (
<div className="qnm-reward-pill">🏴 {node.reward.title}</div> <div className="qnm-reward-pill">🪙 +{node.reward_coins}</div>
)} )}
{node.reward.itemLabel && ( {/* reward_title is now a nested object, not a string */}
{node.reward_title?.name && (
<div className="qnm-reward-pill"> <div className="qnm-reward-pill">
🎁 {node.reward.itemLabel} 🏴 {node.reward_title.name}
</div> </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> </div>
</div> </div>
@ -1064,9 +1032,9 @@ export const QuestNodeModal = ({
) : isLocked ? ( ) : isLocked ? (
<p className="qnm-note">🔒 Locked keep sailing</p> <p className="qnm-note">🔒 Locked keep sailing</p>
) : ( ) : (
/* remaining replaces node.requirement.target - node.progress */
<p className="qnm-note"> <p className="qnm-note">
{progress}% complete · {node.requirement.target - node.progress}{" "} {progress}% complete · {remaining} {reqLabel} remaining
{node.requirement.label} remaining
</p> </p>
)} )}
</div> </div>

View File

@ -7,6 +7,7 @@ import { formatStatus } from "../../lib/utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { SearchOverlay } from "../../components/SearchOverlay"; import { SearchOverlay } from "../../components/SearchOverlay";
import { InfoHeader } from "../../components/InfoHeader"; import { InfoHeader } from "../../components/InfoHeader";
import { InventoryButton } from "../../components/InventoryButton";
// ─── Shared blob/dot background (same as break/results screens) ──────────────── // ─── Shared blob/dot background (same as break/results screens) ────────────────
const DOTS = [ const DOTS = [

View File

@ -1,57 +1,53 @@
import { useState, useRef } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { Lock, CheckCircle } from "lucide-react"; import type {
import type { QuestArc, QuestNode, NodeStatus } from "../../types/quest"; QuestArc,
import { useQuestStore, getQuestSummary } from "../../stores/useQuestStore"; QuestNode,
ClaimedRewardResponse,
} from "../../types/quest";
import { useQuestStore } from "../../stores/useQuestStore";
import { useAuthStore } from "../../stores/authStore";
import { api } from "../../utils/api"; // adjust path to your API client
import { QuestNodeModal } from "../../components/QuestNodeModal"; import { QuestNodeModal } from "../../components/QuestNodeModal";
import { ChestOpenModal } from "../../components/ChestOpenModal"; import { ChestOpenModal } from "../../components/ChestOpenModal";
import { InfoHeader } from "../../components/InfoHeader"; import { InfoHeader } from "../../components/InfoHeader";
// ─── Map geometry (all in SVG user-units, viewBox width = 390) ─────────────── // ─── Map geometry (all in SVG user-units, viewBox width = 390) ───────────────
const VW = 390; // viewBox width — matches typical phone width const VW = 390;
const ROW_GAP = 260; // vertical distance between island centres const ROW_GAP = 265;
const TOP_PAD = 80; // y of first island centre const TOP_PAD = 80;
// Three column x-centres: Left=22%, Centre=50%, Right=78%
const COL_X = [ const COL_X = [
Math.round(VW * 0.22), // 86 Math.round(VW * 0.22), // 86
Math.round(VW * 0.5), // 195 Math.round(VW * 0.5), // 195
Math.round(VW * 0.78), // 304 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[]> = { const ARC_COL_SEQS: Record<string, number[]> = {
east_blue: [0, 1, 2, 0, 1, 2], // steady L→C→R march east_blue: [0, 1, 2, 0, 1, 2],
alabasta: [2, 0, 2, 1, 0, 2], // sharp zigzag, heavy right bias alabasta: [2, 0, 2, 1, 0, 2],
skypiea: [1, 2, 0, 2, 0, 1], // wide sweeping swings C→R→L→R→L→C skypiea: [1, 2, 0, 2, 0, 1],
}; };
const COL_SEQ_DEFAULT = [0, 1, 2, 0, 1, 2]; 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_W = 130;
const CARD_H = 195; const CARD_H = 170; // base height (locked / completed / active)
const CARD_H_CLAIMABLE = 235; // extra room for progress section + claim button
const islandCX = (i: number, arcId: string) => { const islandCX = (i: number, arcId: string) => {
const seq = ARC_COL_SEQS[arcId] ?? COL_SEQ_DEFAULT; const seq = ARC_COL_SEQS[arcId] ?? COL_SEQ_DEFAULT;
return COL_X[seq[i % seq.length]]; return COL_X[seq[i % seq.length]];
}; };
const islandCY = (i: number) => TOP_PAD + i * ROW_GAP; const islandCY = (i: number) => TOP_PAD + i * ROW_GAP;
const svgHeight = (n: number) =>
TOP_PAD + (n - 1) * ROW_GAP + TOP_PAD + CARD_H_CLAIMABLE;
// Total SVG height // ─── Island shapes ────────────────────────────────────────────────────────────
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 = [ const SHAPES = [
// 0: fat round atoll
`<ellipse cx="0" cy="0" rx="57" ry="33"/>`, `<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"/>`, `<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"/>`, `<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"/>`, `<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"/>`, `<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"/>`, `<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"/>`,
]; ];
@ -270,33 +266,123 @@ const STYLES = `
.qm-island-in { animation: qmIslandIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both; } .qm-island-in { animation: qmIslandIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both; }
`; `;
// ─── Data ───────────────────────────────────────────────────────────────────── // ─── Arc theme generator ──────────────────────────────────────────────────────
const TERRAIN: Record<string, { l: string; m: string; d: string; s: string }> = // Deterministic pseudo-random theme derived from arc.id string so the same arc
{ // always gets the same colours across renders/sessions — no server field needed.
east_blue: {
l: "#5eead4", export interface ArcTheme {
m: "#0d9488", accent: string; // bright highlight colour
d: "#0f766e", accentDark: string; // darker variant for shadows/gradients
s: "rgba(13,148,136,0.55)", bgFrom: string; // banner gradient start
}, bgTo: string; // banner gradient end
alabasta: { emoji: string; // banner / tab icon
l: "#fcd34d", terrain: { l: string; m: string; d: string; s: string }; // island fill colours
m: "#d97706", decos: [string, string, string]; // SVG decoration emojis
d: "#92400e", }
s: "rgba(146,64,14,0.65)",
}, /** Cheap seeded PRNG — Mulberry32. Returns a function that yields [0,1) floats. */
skypiea: { const mkRng = (seed: number) => {
l: "#d8b4fe", let s = seed >>> 0;
m: "#9333ea", return () => {
d: "#6b21a8", s += 0x6d2b79f5;
s: "rgba(107,33,168,0.55)", let t = Math.imul(s ^ (s >>> 15), 1 | s);
}, t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}; };
const DECOS: Record<string, [string, string, string]> = {
east_blue: ["🌴", "🌿", "🌴"],
alabasta: ["🌵", "🏺", "🌵"],
skypiea: ["☁️", "✨", "☁️"],
}; };
/** Turn an arc.id string into a stable 32-bit seed via djb2. */
const strToSeed = (str: string): number => {
let h = 5381;
for (let i = 0; i < str.length; i++)
h = (Math.imul(h, 33) ^ str.charCodeAt(i)) >>> 0;
return h;
};
/** Convert HSL values (all 0-1) to a CSS hex colour. */
const hslToHex = (h: number, s: number, l: number): string => {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h * 12) % 12;
const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
return Math.round(255 * c)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`;
};
const ARC_EMOJIS = [
"⚓",
"🏴‍☠️",
"🗺️",
"⚔️",
"🌊",
"🔱",
"☠️",
"🧭",
"💎",
"🏝️",
"⛵",
"🌋",
];
const DECO_SETS: [string, string, string][] = [
["🌴", "🌿", "🌴"],
["🌵", "🏺", "🌵"],
["☁️", "✨", "☁️"],
["🪨", "🌾", "🪨"],
["🍄", "🌸", "🍄"],
["🔥", "💀", "🔥"],
["❄️", "🌨️", "❄️"],
["🌺", "🦜", "🌺"],
];
/**
* Generates a fully deterministic colour theme from arc.id.
* The same id always produces the same theme — no randomness at render time.
*/
export const generateArcTheme = (arc: QuestArc): ArcTheme => {
const rng = mkRng(strToSeed(arc.id));
// Pick a hue, then build a coherent palette around it
const hue = rng(); // 0-1 (= 0°-360°)
const hueShift = 0.05 + rng() * 0.1; // slight shift for dark variant
const satHigh = 0.55 + rng() * 0.35; // 0.55-0.90
const satLow = satHigh * (0.5 + rng() * 0.3); // darker bg is less saturated
const accent = hslToHex(hue, satHigh, 0.72); // bright, light
const accentDark = hslToHex(hue, satHigh, 0.3); // same hue, deep
const bgFrom = hslToHex(hue, satLow, 0.14); // very dark, rich
const bgTo = hslToHex(hue + hueShift, satLow, 0.22); // slightly lighter
// Terrain: light highlight, mid tone, dark shadow, shadow rgba
const tL = hslToHex(hue, satHigh, 0.68);
const tM = hslToHex(hue, satHigh, 0.42);
const tD = hslToHex(hue, satHigh * 0.85, 0.22);
// Shadow as rgba — parse the dark hex back to rgb values
const sd = parseInt(tD.slice(1, 3), 16);
const sg = parseInt(tD.slice(3, 5), 16);
const sb = parseInt(tD.slice(5, 7), 16);
const terrain = { l: tL, m: tM, d: tD, s: `rgba(${sd},${sg},${sb},0.6)` };
// Pick emoji + deco set deterministically
const emojiIdx = Math.floor(rng() * ARC_EMOJIS.length);
const decoIdx = Math.floor(rng() * DECO_SETS.length);
const emoji = ARC_EMOJIS[emojiIdx];
const decos = DECO_SETS[decoIdx];
return { accent, accentDark, bgFrom, bgTo, emoji, terrain, decos };
};
/** Cache so we never regenerate a theme for the same arc within a session. */
const themeCache = new Map<string, ArcTheme>();
const getArcTheme = (arc: QuestArc): ArcTheme => {
if (!themeCache.has(arc.id)) themeCache.set(arc.id, generateArcTheme(arc));
return themeCache.get(arc.id)!;
};
// ─── Requirement helpers ───────────────────────────────────────────────────────
// req_type → display icon (unchanged from original REQ_ICON map)
const REQ_ICON: Record<string, string> = { const REQ_ICON: Record<string, string> = {
questions: "❓", questions: "❓",
accuracy: "🎯", accuracy: "🎯",
@ -306,6 +392,30 @@ const REQ_ICON: Record<string, string> = {
xp: "⚡", xp: "⚡",
leaderboard: "🏆", leaderboard: "🏆",
}; };
// req_type → human-readable label (replaces the old requirement.label field)
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",
};
// req_type → emoji shown on the island body (replaces old node.emoji field)
const REQ_EMOJI: Record<string, string> = {
questions: "❓",
accuracy: "🎯",
streak: "🔥",
sessions: "📚",
topics: "🗺️",
xp: "⚡",
leaderboard: "🏆",
};
// ─── Sea foam bubbles ─────────────────────────────────────────────────────────
const FOAM = Array.from({ length: 22 }, (_, i) => ({ const FOAM = Array.from({ length: 22 }, (_, i) => ({
id: i, id: i,
w: 10 + ((i * 17 + 7) % 24), w: 10 + ((i * 17 + 7) % 24),
@ -314,14 +424,24 @@ const FOAM = Array.from({ length: 22 }, (_, i) => ({
dur: `${4 + ((i * 7) % 7)}s`, dur: `${4 + ((i * 7) % 7)}s`,
delay: `${(i * 3) % 5}s`, delay: `${(i * 3) % 5}s`,
})); }));
// ─── Helpers ──────────────────────────────────────────────────────────────────
const completedCount = (arc: QuestArc) => const completedCount = (arc: QuestArc) =>
arc.nodes.filter((n) => n.status === "completed").length; arc.nodes.filter((n) => n.status === "completed").length;
// Truncate island label to keep SVG tidy
const truncate = (str: string | undefined, max = 14): string => {
if (!str) return "";
return str.length > max ? str.slice(0, max - 1) + "…" : str;
};
// ─── SVG Island node ────────────────────────────────────────────────────────── // ─── SVG Island node ──────────────────────────────────────────────────────────
const IslandNode = ({ const IslandNode = ({
node, node,
arcId,
accent, accent,
terrain,
decos,
userXp,
index, index,
cx, cx,
cy, cy,
@ -329,24 +449,27 @@ const IslandNode = ({
onClaim, onClaim,
}: { }: {
node: QuestNode; node: QuestNode;
arcId: string;
accent: string; accent: string;
terrain: ArcTheme["terrain"];
decos: ArcTheme["decos"];
userXp: number;
index: number; index: number;
cx: number; cx: number;
cy: number; cy: number;
onTap: (n: QuestNode) => void; onTap: (n: QuestNode) => void;
onClaim: (n: QuestNode) => void; onClaim: (n: QuestNode) => void;
}) => { }) => {
const terrain = TERRAIN[arcId] ?? TERRAIN.east_blue; // node.status is typed as string from the API; normalise to expected literals
const decos = DECOS[arcId] ?? DECOS.east_blue; const status = node.status as "LOCKED" | "ACTIVE" | "CLAIMABLE" | "COMPLETED";
const isCompleted = status === "COMPLETED";
const isClaimable = status === "CLAIMABLE";
const isActive = status === "ACTIVE";
const isLocked = status === "LOCKED";
const isCompleted = node.status === "completed"; // Progress percentage — uses new current_value / req_target fields
const isClaimable = node.status === "claimable";
const isActive = node.status === "active";
const isLocked = node.status === "locked";
const pct = Math.min( const pct = Math.min(
100, 100,
Math.round((node.progress / node.requirement.target) * 100), Math.round((node.current_value / node.req_target) * 100),
); );
const hiC = isLocked ? "#4b5563" : isCompleted ? "#6ee7b7" : terrain.l; const hiC = isLocked ? "#4b5563" : isCompleted ? "#6ee7b7" : terrain.l;
@ -354,14 +477,17 @@ const IslandNode = ({
const loC = isLocked ? "#1f2937" : isCompleted ? "#065f46" : terrain.d; const loC = isLocked ? "#1f2937" : isCompleted ? "#065f46" : terrain.d;
const shdC = isLocked ? "rgba(0,0,0,0.5)" : terrain.s; const shdC = isLocked ? "rgba(0,0,0,0.5)" : terrain.s;
const gradId = `grad-${node.id}`; const gradId = `grad-${node.node_id}`;
const clipId = `clip-${node.id}`; const clipId = `clip-${node.node_id}`;
const shadowId = `shadow-${node.id}`; const shadowId = `shadow-${node.node_id}`;
const glowId = `glow-${node.id}`; const glowId = `glow-${node.node_id}`;
const shapeIdx = index % SHAPES.length; const shapeIdx = index % SHAPES.length;
const LAND_H = 38; const LAND_H = 38;
const cardTop = cy + LAND_H + 18; const cardTop = cy + LAND_H + 18;
// Claimable cards render progress section + button — need more vertical room.
// All other statuses fit in the base height.
const cardH = isClaimable ? CARD_H_CLAIMABLE : CARD_H;
const statusCard = isClaimable const statusCard = isClaimable
? "is-claimable" ? "is-claimable"
@ -371,6 +497,9 @@ const IslandNode = ({
? "is-locked" ? "is-locked"
: "is-completed"; : "is-completed";
// Derive island emoji from req_type (replaces old node.emoji field)
const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️";
return ( return (
<g <g
style={{ cursor: isLocked ? "default" : "pointer" }} style={{ cursor: isLocked ? "default" : "pointer" }}
@ -546,7 +675,7 @@ const IslandNode = ({
</text> </text>
)} )}
{/* Quest emoji */} {/* Node emoji — derived from req_type */}
{!isLocked && ( {!isLocked && (
<text <text
x={cx} x={cx}
@ -556,7 +685,7 @@ const IslandNode = ({
dominantBaseline="middle" dominantBaseline="middle"
style={{ filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.5))" }} style={{ filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.5))" }}
> >
{node.emoji} {nodeEmoji}
</text> </text>
)} )}
@ -575,7 +704,7 @@ const IslandNode = ({
</g> </g>
)} )}
{/* Island name label */} {/* Island name label — uses node.name (truncated) */}
<text <text
x={cx} x={cx}
y={cy + LAND_H + 10} y={cy + LAND_H + 10}
@ -586,7 +715,7 @@ const IslandNode = ({
textAnchor="middle" textAnchor="middle"
letterSpacing="0.1em" letterSpacing="0.1em"
> >
{node.islandName?.toUpperCase()} {truncate(node.name).toUpperCase()}
</text> </text>
{/* Info card via foreignObject */} {/* Info card via foreignObject */}
@ -594,7 +723,7 @@ const IslandNode = ({
x={cx - CARD_W / 2} x={cx - CARD_W / 2}
y={cardTop} y={cardTop}
width={CARD_W} width={CARD_W}
height={CARD_H} height={cardH}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@ -604,34 +733,42 @@ const IslandNode = ({
onClick={() => !isLocked && onTap(node)} onClick={() => !isLocked && onTap(node)}
> >
<div className="qm-info-row1"> <div className="qm-info-row1">
<p className="qm-info-title">{node.title}</p> {/* node.name replaces old node.title */}
<p className="qm-info-title">{node.name ?? "—"}</p>
{/* Live XP from auth store */}
<div className="qm-xp-badge"> <div className="qm-xp-badge">
<span style={{ fontSize: "0.58rem" }}></span> <span style={{ fontSize: "0.58rem" }}></span>
<span className="qm-xp-badge-val">+{node.reward.xp}</span> <span className="qm-xp-badge-val">{userXp} XP</span>
</div> </div>
</div> </div>
{(isActive || isClaimable) && ( {(isActive || isClaimable) && (
<> <>
<div className="qm-prog-track"> <div className="qm-prog-track">
<div className="qm-prog-fill" style={{ width: `${pct}%` }} /> <div className="qm-prog-fill" style={{ width: `${pct}%` }} />
</div> </div>
{/* req_type + current_value / req_target + derived label */}
<p className="qm-prog-label"> <p className="qm-prog-label">
{REQ_ICON[node.requirement.type]}&nbsp; {REQ_ICON[node.req_type]}&nbsp;
{node.progress}/{node.requirement.target}{" "} {node.current_value}/{node.req_target}&nbsp;
{node.requirement.label} {REQ_LABEL[node.req_type] ?? node.req_type}
</p> </p>
</> </>
)} )}
{isLocked && ( {isLocked && (
<p className="qm-prog-label"> <p className="qm-prog-label">
🔒 {node.requirement.target} {node.requirement.label} to unlock 🔒 {node.req_target} {REQ_LABEL[node.req_type] ?? node.req_type}{" "}
to unlock
</p> </p>
)} )}
{isCompleted && ( {isCompleted && (
<p className="qm-prog-label" style={{ color: "#4ade80" }}> <p className="qm-prog-label" style={{ color: "#4ade80" }}>
Conquered! Conquered!
</p> </p>
)} )}
{isClaimable && ( {isClaimable && (
<button <button
className="qm-claim-btn" className="qm-claim-btn"
@ -747,37 +884,206 @@ const RoutePath = ({
// ─── Main ───────────────────────────────────────────────────────────────────── // ─── Main ─────────────────────────────────────────────────────────────────────
export const QuestMap = () => { export const QuestMap = () => {
// ── Store — select ONLY stable primitives/actions, never derived functions ── // ── Store ──
const arcs = useQuestStore((s) => s.arcs); const arcs = useQuestStore((s) => s.arcs);
const activeArcId = useQuestStore((s) => s.activeArcId); const activeArcId = useQuestStore((s) => s.activeArcId);
const setActiveArc = useQuestStore((s) => s.setActiveArc); const setActiveArc = useQuestStore((s) => s.setActiveArc);
const claimNode = useQuestStore((s) => s.claimNode); const claimNode = useQuestStore((s) => s.claimNode);
const syncFromAPI = useQuestStore((s) => s.syncFromAPI);
const user = useAuthStore((s) => s.user);
const token = useAuthStore((s) => s.token);
const userXp = user?.total_xp ?? 0;
// Derived values — computed from arcs outside the selector, never causes loops // ── Fetch state ──
const summary = getQuestSummary(arcs); const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
// ── Local UI state (doesn't need to be global) ── // ── Claim state ──
const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);
const [claimingNode, setClaimingNode] = useState<QuestNode | null>(null); const [claimingNode, setClaimingNode] = useState<QuestNode | null>(null);
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
null,
);
const [claimLoading, setClaimLoading] = useState(false);
const [claimError, setClaimError] = useState<string | null>(null);
// ── UI state ──
const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0]; // ── Fetch journey on mount ────────────────────────────────────────────────
const done = completedCount(arc); useEffect(() => {
const pct = Math.round((done / arc.nodes.length) * 100); if (!token) return;
let cancelled = false;
const handleClaim = (node: QuestNode) => setClaimingNode(node); const fetchJourney = async () => {
const handleChestClose = () => { try {
if (!claimingNode) return; setLoading(true);
claimNode(arc.id, claimingNode.id); // store handles state update + next unlock setFetchError(null);
setClaimingNode(null); const data = await api.fetchUserJourney(token);
if (!cancelled) syncFromAPI(data);
} catch (err) {
if (!cancelled)
setFetchError(
err instanceof Error ? err.message : "Failed to load quests",
);
} finally {
if (!cancelled) setLoading(false);
}
}; };
const nodes = arc.nodes; fetchJourney();
const centres = nodes.map((_, i) => ({ return () => {
cancelled = true;
};
}, [token, syncFromAPI]);
// ── Derived ───────────────────────────────────────────────────────────────
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];
const theme = arc ? getArcTheme(arc) : null;
const done = arc ? completedCount(arc) : 0;
const pct = arc ? Math.round((done / arc.nodes.length) * 100) : 0;
// ── Claim flow ────────────────────────────────────────────────────────────
// Step 1: user taps "Open Chest" — open the modal immediately (animation
// starts) and fire the API call in parallel so the result is usually ready
// by the time the chest animation finishes (~2.5s).
const handleClaim = useCallback(
async (node: QuestNode) => {
if (!token) return;
setClaimingNode(node);
setClaimResult(null);
setClaimError(null);
setClaimLoading(true);
try {
const result = await api.claimReward(token, node.node_id);
setClaimResult(result);
} catch (err) {
setClaimError(err instanceof Error ? err.message : "Claim failed");
} finally {
setClaimLoading(false);
}
},
[token],
);
// Step 2: user taps "Set Sail" in ChestOpenModal — commit to store & close.
const handleChestClose = useCallback(() => {
if (!claimingNode) return;
const titlesUnlocked = Array.isArray(claimResult?.title_unlocked)
? claimResult!.title_unlocked
: claimResult?.title_unlocked
? [claimResult.title_unlocked]
: [];
claimNode(
arc.id,
claimingNode.node_id,
claimResult?.xp_awarded ?? 0,
titlesUnlocked.map((t) => t.name),
);
setClaimingNode(null);
setClaimResult(null);
setClaimError(null);
}, [claimingNode, claimResult, arc, claimNode]);
// ── Loading screen ────────────────────────────────────────────────────────
if (loading) {
return (
<div
className="qm-screen"
style={{ alignItems: "center", justifyContent: "center" }}
>
<style>{STYLES}</style>
<div
style={{
textAlign: "center",
color: "rgba(255,255,255,0.5)",
fontFamily: "'Nunito',sans-serif",
}}
>
<div
style={{
fontSize: "2.5rem",
marginBottom: "1rem",
animation: "qmFabFloat 2s ease-in-out infinite",
}}
>
</div>
<p
style={{
fontSize: "0.85rem",
fontWeight: 800,
letterSpacing: "0.1em",
}}
>
CHARTING YOUR COURSE...
</p>
</div>
</div>
);
}
// ── Error screen ──────────────────────────────────────────────────────────
if (fetchError || !arc) {
return (
<div
className="qm-screen"
style={{
alignItems: "center",
justifyContent: "center",
padding: "2rem",
}}
>
<style>{STYLES}</style>
<div
style={{
textAlign: "center",
color: "rgba(255,255,255,0.5)",
fontFamily: "'Nunito',sans-serif",
}}
>
<div style={{ fontSize: "2.5rem", marginBottom: "1rem" }}>🌊</div>
<p
style={{
fontSize: "0.85rem",
fontWeight: 800,
color: "#ef4444",
marginBottom: "0.5rem",
}}
>
{fetchError ?? "No quest data found"}
</p>
<button
onClick={() => window.location.reload()}
style={{
marginTop: "1rem",
padding: "0.5rem 1.25rem",
borderRadius: "100px",
border: "1px solid rgba(255,255,255,0.15)",
background: "transparent",
color: "rgba(255,255,255,0.5)",
cursor: "pointer",
fontFamily: "'Nunito',sans-serif",
fontWeight: 800,
fontSize: "0.75rem",
}}
>
Try again
</button>
</div>
</div>
);
}
const sorted = [...arc.nodes].sort(
(a, b) => a.sequence_order - b.sequence_order,
);
const centres = sorted.map((_, i) => ({
x: islandCX(i, arc.id), x: islandCX(i, arc.id),
y: islandCY(i), y: islandCY(i),
})); }));
const totalSvgH = svgHeight(nodes.length); const totalSvgH = svgHeight(sorted.length);
return ( return (
<div className="qm-screen"> <div className="qm-screen">
@ -785,48 +1091,29 @@ export const QuestMap = () => {
{/* Header */} {/* Header */}
<div className="qm-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> */}
<InfoHeader mode="QUEST_EXTENDED" /> <InfoHeader mode="QUEST_EXTENDED" />
<div className="qm-arc-tabs"> <div className="qm-arc-tabs">
{arcs.map((a) => ( {[...arcs]
.sort((a, b) => a.sequence_order - b.sequence_order)
.map((a) => {
const t = getArcTheme(a);
return (
<button <button
key={a.id} key={a.id}
className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`} className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`}
style={{ "--arc-accent": a.accentColor } as React.CSSProperties} style={{ "--arc-accent": t.accent } as React.CSSProperties}
onClick={() => { onClick={() => {
setActiveArc(a.id); setActiveArc(a.id);
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}} }}
> >
{a.emoji} {a.name} {t.emoji} {a.name}
{a.nodes.some((n) => n.status === "claimable") && ( {a.nodes.some((n) => n.status === "CLAIMABLE") && (
<span className="qm-tab-dot" /> <span className="qm-tab-dot" />
)} )}
</button> </button>
))} );
})}
</div> </div>
</div> </div>
@ -855,12 +1142,12 @@ export const QuestMap = () => {
<div <div
className="qm-arc-banner" className="qm-arc-banner"
style={{ style={{
background: `linear-gradient(135deg,${arc.bgFrom}dd,${arc.bgTo}ee)`, background: `linear-gradient(135deg,${theme!.bgFrom}dd,${theme!.bgTo}ee)`,
}} }}
> >
<div className="qm-arc-banner-bg-emoji">{arc.emoji}</div> <div className="qm-arc-banner-bg-emoji">{theme!.emoji}</div>
<p className="qm-arc-banner-name">{arc.name}</p> <p className="qm-arc-banner-name">{arc.name}</p>
<p className="qm-arc-banner-sub">{arc.subtitle}</p> <p className="qm-arc-banner-sub">{arc.description}</p>
<div className="qm-arc-banner-prog"> <div className="qm-arc-banner-prog">
<div className="qm-arc-banner-track"> <div className="qm-arc-banner-track">
<div <div
@ -874,21 +1161,20 @@ export const QuestMap = () => {
</div> </div>
</div> </div>
{/* ── Single SVG canvas for the whole map ── */} {/* SVG map canvas */}
<svg <svg
className="qm-map-svg" className="qm-map-svg"
viewBox={`0 0 ${VW} ${totalSvgH}`} viewBox={`0 0 ${VW} ${totalSvgH}`}
height={totalSvgH} height={totalSvgH}
preserveAspectRatio="xMidYMin meet" preserveAspectRatio="xMidYMin meet"
> >
{/* Routes drawn FIRST (behind islands) */} {sorted.map((node, i) => {
{nodes.map((node, i) => { if (i >= sorted.length - 1) return null;
if (i >= nodes.length - 1) return null;
const c1 = centres[i]; const c1 = centres[i];
const c2 = centres[i + 1]; const c2 = centres[i + 1];
const ship = const ship =
node.status === "completed" && node.status === "completed" &&
nodes[i + 1]?.status === "active"; sorted[i + 1]?.status === "active";
return ( return (
<RoutePath <RoutePath
key={`route-${i}`} key={`route-${i}`}
@ -897,19 +1183,20 @@ export const QuestMap = () => {
x2={c2.x} x2={c2.x}
y2={c2.y} y2={c2.y}
done={node.status === "completed"} done={node.status === "completed"}
accent={arc.accentColor} accent={theme!.accent}
showShip={ship} showShip={ship}
/> />
); );
})} })}
{/* Islands drawn on top */} {sorted.map((node, i) => (
{nodes.map((node, i) => (
<IslandNode <IslandNode
key={node.id} key={node.node_id}
node={node} node={node}
arcId={arc.id} accent={theme!.accent}
accent={arc.accentColor} terrain={theme!.terrain}
decos={theme!.decos}
userXp={userXp}
index={i} index={i}
cx={centres[i].x} cx={centres[i].x}
cy={centres[i].y} cy={centres[i].y}
@ -918,8 +1205,7 @@ export const QuestMap = () => {
/> />
))} ))}
{/* Arc complete seal */} {done === sorted.length && (
{done === nodes.length && (
<g transform={`translate(${VW / 2},${totalSvgH - 60})`}> <g transform={`translate(${VW / 2},${totalSvgH - 60})`}>
<circle <circle
r="42" r="42"
@ -979,10 +1265,11 @@ export const QuestMap = () => {
{selectedNode && ( {selectedNode && (
<QuestNodeModal <QuestNodeModal
node={selectedNode} node={selectedNode}
arcAccent={arc.accentColor} arc={arc}
arcDark={arc.accentDark} arcAccent={theme!.accent}
arcDark={theme!.accentDark}
arcId={arc.id} arcId={arc.id}
nodeIndex={arc.nodes.findIndex((n) => n.id === selectedNode.id)} nodeIndex={selectedNode.sequence_order}
onClose={() => setSelectedNode(null)} onClose={() => setSelectedNode(null)}
onClaim={() => { onClaim={() => {
setSelectedNode(null); setSelectedNode(null);
@ -990,8 +1277,38 @@ export const QuestMap = () => {
}} }}
/> />
)} )}
{claimingNode && ( {claimingNode && (
<ChestOpenModal node={claimingNode} onClose={handleChestClose} /> <ChestOpenModal
node={claimingNode}
claimResult={claimResult}
onClose={handleChestClose}
/>
)}
{/* Claim error toast — shown if API call failed but modal is already open */}
{claimError && (
<div
style={{
position: "fixed",
bottom: "calc(2rem + env(safe-area-inset-bottom))",
left: "50%",
transform: "translateX(-50%)",
zIndex: 100,
background: "#7f1d1d",
border: "1px solid #ef4444",
borderRadius: "12px",
padding: "0.6rem 1.1rem",
color: "white",
fontFamily: "'Nunito',sans-serif",
fontSize: "0.78rem",
fontWeight: 800,
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
whiteSpace: "nowrap",
}}
>
{claimError} your progress is saved
</div>
)} )}
</div> </div>
); );

View File

@ -261,7 +261,7 @@ const TARGETED_XP = 15;
const TARGETED_SCORE = 15; const TARGETED_SCORE = 15;
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => { const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
const { userMetrics, setUserMetrics } = useExamConfigStore(); const { userMetrics } = useExamConfigStore();
const previousXP = userMetrics.xp ?? 0; const previousXP = userMetrics.xp ?? 0;
const gainedXP = TARGETED_XP; const gainedXP = TARGETED_XP;
const levelMinXP = Math.floor(previousXP / 100) * 100; const levelMinXP = Math.floor(previousXP / 100) * 100;
@ -269,14 +269,6 @@ const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
const currentLevel = Math.floor(previousXP / 100) + 1; const currentLevel = Math.floor(previousXP / 100) + 1;
const displayXP = useCountUp(gainedXP); const displayXP = useCountUp(gainedXP);
useEffect(() => {
setUserMetrics({
xp: previousXP,
questions: 0,
streak: 0,
});
}, []);
return ( return (
<div className="results-screen"> <div className="results-screen">
<style>{STYLES}</style> <style>{STYLES}</style>
@ -397,20 +389,12 @@ export const Results = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const results = useResults((s) => s.results); const results = useResults((s) => s.results);
const clearResults = useResults((s) => s.clearResults); const clearResults = useResults((s) => s.clearResults);
const { setUserMetrics, payload } = useExamConfigStore(); const { payload } = useExamConfigStore();
const isTargeted = payload?.mode === "TARGETED"; const isTargeted = payload?.mode === "TARGETED";
useEffect(() => {
if (results)
setUserMetrics({
xp: results.total_xp,
questions: results.correct_count,
streak: 0,
});
}, [results]);
function handleFinishExam() { function handleFinishExam() {
useExamConfigStore.getState().clearPayload(); useExamConfigStore.getState().clearPayload();
clearResults(); clearResults();
navigate("/student/home"); navigate("/student/home");
} }

View File

@ -0,0 +1,120 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type {
InventoryItem,
ActiveEffect,
UserInventory,
} from "../types/quest";
// ─── Store interface ──────────────────────────────────────────────────────────
interface InventoryStore {
// Raw inventory from API
items: InventoryItem[];
activeEffects: ActiveEffect[];
// Loading / error
loading: boolean;
activatingId: string | null; // item id currently being activated
error: string | null;
lastActivatedId: string | null; // shows success state briefly
// Actions
syncFromAPI: (inv: UserInventory) => void;
activateItemOptimistic: (itemId: string) => void;
activateItemSuccess: (inv: UserInventory, itemId: string) => void;
activateItemError: (itemId: string, error: string) => void;
clearLastActivated: () => void;
setLoading: (v: boolean) => void;
}
export const useInventoryStore = create<InventoryStore>()(
persist(
(set) => ({
items: [],
activeEffects: [],
loading: false,
activatingId: null,
error: null,
lastActivatedId: null,
syncFromAPI: (inv) =>
set({ items: inv.items, activeEffects: inv.active_effects }),
// Optimistic — mark as "activating" immediately for instant UI feedback
activateItemOptimistic: (itemId) =>
set({ activatingId: itemId, error: null }),
// On API success — replace inventory with fresh server state
activateItemSuccess: (inv, itemId) =>
set({
items: inv.items,
activeEffects: inv.active_effects,
activatingId: null,
lastActivatedId: itemId,
}),
activateItemError: (itemId, error) => set({ activatingId: null, error }),
clearLastActivated: () => set({ lastActivatedId: null }),
setLoading: (v) => set({ loading: v }),
}),
{
name: "inventory-store",
storage: createJSONStorage(() => localStorage),
// Persist items + active effects so the app can show active item banners
// without waiting for a network request on every mount
partialize: (state) => ({
items: state.items,
activeEffects: state.activeEffects,
}),
},
),
);
// ─── Selector helpers (call these in components, not inside the store) ─────────
/** Returns true if any active effect has the given effect_type */
export function hasActiveEffect(
activeEffects: ActiveEffect[],
effectType: string,
): boolean {
const now = Date.now();
return activeEffects.some(
(e) =>
e.item.effect_type === effectType &&
new Date(e.expires_at).getTime() > now,
);
}
/** Returns the active effect for a given effect_type, or null */
export function getActiveEffect(
activeEffects: ActiveEffect[],
effectType: string,
): ActiveEffect | null {
const now = Date.now();
return (
activeEffects.find(
(e) =>
e.item.effect_type === effectType &&
new Date(e.expires_at).getTime() > now,
) ?? null
);
}
/** Returns all non-expired active effects */
export function getLiveEffects(activeEffects: ActiveEffect[]): ActiveEffect[] {
const now = Date.now();
return activeEffects.filter((e) => new Date(e.expires_at).getTime() > now);
}
/** Formats time remaining as "2h 14m" or "43m" */
export function formatTimeLeft(expiresAt: string): string {
const msLeft = new Date(expiresAt).getTime() - Date.now();
if (msLeft <= 0) return "Expired";
const totalMin = Math.floor(msLeft / 60_000);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}

View File

@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; import { persist, createJSONStorage } from "zustand/middleware";
import type { QuestArc, QuestNode, NodeStatus } from "../types/quest"; import type { QuestArc, QuestNode } from "../types/quest";
import { CREW_RANKS } from "../types/quest"; import { CREW_RANKS } from "../types/quest";
import { QUEST_ARCS } from "../data/questData"; import { QUEST_ARCS } from "../data/questData";
@ -21,24 +21,37 @@ export interface QuestSummary {
activeNodes: number; activeNodes: number;
claimableNodes: number; claimableNodes: number;
lockedNodes: number; lockedNodes: number;
totalXP: number; // totalXP removed — node definitions no longer carry an XP value.
earnedXP: number; // Awarded XP only comes back from ClaimedRewardResponse at claim time.
earnedXP: number; // accumulated from claim responses, stored in state
arcsCompleted: number; arcsCompleted: number;
totalArcs: number; totalArcs: number;
earnedTitles: string[]; earnedTitles: string[]; // accumulated from claim responses, stored in state
crewRank: CrewRank; crewRank: CrewRank;
} }
// ─── Store — ONLY raw state + actions, never derived values ─────────────────── // ─── Store ────────────────────────────────────────────────────────────────────
// 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 { interface QuestStore {
arcs: QuestArc[]; arcs: QuestArc[];
activeArcId: string; activeArcId: string;
// XP and titles are no longer derivable from node fields alone —
// they come from ClaimedRewardResponse at claim time, so we track them here.
earnedXP: number;
earnedTitles: string[];
setActiveArc: (arcId: string) => void; setActiveArc: (arcId: string) => void;
claimNode: (arcId: string, nodeId: string) => void; /**
* Call this after a successful /journey/claim API response.
* Pass the xp and titles returned by ClaimedRewardResponse so the store
* stays in sync without needing to re-fetch the whole journey.
*/
claimNode: (
arcId: string,
nodeId: string,
xpAwarded?: number,
titlesAwarded?: string[],
) => void;
syncFromAPI: (arcs: QuestArc[]) => void; syncFromAPI: (arcs: QuestArc[]) => void;
} }
@ -47,22 +60,29 @@ export const useQuestStore = create<QuestStore>()(
(set) => ({ (set) => ({
arcs: QUEST_ARCS, arcs: QUEST_ARCS,
activeArcId: QUEST_ARCS[0].id, activeArcId: QUEST_ARCS[0].id,
earnedXP: 0,
earnedTitles: [],
setActiveArc: (arcId) => set({ activeArcId: arcId }), setActiveArc: (arcId) => set({ activeArcId: arcId }),
claimNode: (arcId, nodeId) => claimNode: (arcId, nodeId, xpAwarded = 0, titlesAwarded = []) =>
set((state) => ({ set((state) => ({
// Accumulate XP and titles from the claim response
earnedXP: state.earnedXP + xpAwarded,
earnedTitles: [...state.earnedTitles, ...titlesAwarded],
arcs: state.arcs.map((arc) => { arcs: state.arcs.map((arc) => {
if (arc.id !== arcId) return arc; if (arc.id !== arcId) return arc;
const nodeIdx = arc.nodes.findIndex((n) => n.id === nodeId); // node_id is the new primary key — replaces old n.id
const nodeIdx = arc.nodes.findIndex((n) => n.node_id === nodeId);
if (nodeIdx === -1) return arc; if (nodeIdx === -1) return arc;
return { return {
...arc, ...arc,
nodes: arc.nodes.map((n, i) => { nodes: arc.nodes.map((n, i) => {
if (n.id === nodeId) if (n.node_id === nodeId) return { ...n, status: "completed" };
return { ...n, status: "completed" as NodeStatus }; // Unlock the next locked node in sequence
if (i === nodeIdx + 1 && n.status === "locked") if (i === nodeIdx + 1 && n.status === "locked")
return { ...n, status: "active" as NodeStatus }; return { ...n, status: "active" };
return n; return n;
}), }),
}; };
@ -77,34 +97,21 @@ export const useQuestStore = create<QuestStore>()(
partialize: (state) => ({ partialize: (state) => ({
arcs: state.arcs, arcs: state.arcs,
activeArcId: state.activeArcId, activeArcId: state.activeArcId,
earnedXP: state.earnedXP,
earnedTitles: state.earnedTitles,
}), }),
}, },
), ),
); );
// ─── Standalone helper functions ────────────────────────────────────────────── // ─── 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 { export function getCrewRank(earnedXP: number): CrewRank {
return arcs // Accepts earnedXP directly — no longer iterates nodes (reward.xp is gone)
.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]; const ladder = [...CREW_RANKS];
let idx = 0; let idx = 0;
for (let i = ladder.length - 1; i >= 0; i--) { for (let i = ladder.length - 1; i >= 0; i--) {
if (xp >= ladder[i].xpRequired) { if (earnedXP >= ladder[i].xpRequired) {
idx = i; idx = i;
break; break;
} }
@ -116,7 +123,7 @@ export function getCrewRank(arcs: QuestArc[]): CrewRank {
progressToNext: nextRank progressToNext: nextRank
? Math.min( ? Math.min(
1, 1,
(xp - current.xpRequired) / (earnedXP - current.xpRequired) /
(nextRank.xpRequired - current.xpRequired), (nextRank.xpRequired - current.xpRequired),
) )
: 1, : 1,
@ -126,25 +133,25 @@ export function getCrewRank(arcs: QuestArc[]): CrewRank {
}; };
} }
export function getQuestSummary(arcs: QuestArc[]): QuestSummary { export function getQuestSummary(
arcs: QuestArc[],
earnedXP: number,
earnedTitles: string[],
): QuestSummary {
const allNodes = arcs.flatMap((a) => a.nodes); const allNodes = arcs.flatMap((a) => a.nodes);
const earnedXP = getEarnedXP(arcs);
return { return {
totalNodes: allNodes.length, totalNodes: allNodes.length,
completedNodes: allNodes.filter((n) => n.status === "completed").length, completedNodes: allNodes.filter((n) => n.status === "completed").length,
activeNodes: allNodes.filter((n) => n.status === "active").length, activeNodes: allNodes.filter((n) => n.status === "active").length,
claimableNodes: allNodes.filter((n) => n.status === "claimable").length, claimableNodes: allNodes.filter((n) => n.status === "claimable").length,
lockedNodes: allNodes.filter((n) => n.status === "locked").length, lockedNodes: allNodes.filter((n) => n.status === "locked").length,
totalXP: allNodes.reduce((s, n) => s + n.reward.xp, 0),
earnedXP, earnedXP,
arcsCompleted: arcs.filter((a) => arcsCompleted: arcs.filter((a) =>
a.nodes.every((n) => n.status === "completed"), a.nodes.every((n) => n.status === "completed"),
).length, ).length,
totalArcs: arcs.length, totalArcs: arcs.length,
earnedTitles: allNodes earnedTitles,
.filter((n) => n.status === "completed" && n.reward.title) crewRank: getCrewRank(earnedXP),
.map((n) => n.reward.title!),
crewRank: getCrewRank(arcs),
}; };
} }
@ -153,11 +160,12 @@ export function getClaimableCount(arcs: QuestArc[]): number {
.length; .length;
} }
// node_id is the new primary key — replaces old n.id
export function getNode( export function getNode(
arcs: QuestArc[], arcs: QuestArc[],
nodeId: string, nodeId: string,
): QuestNode | undefined { ): QuestNode | undefined {
return arcs.flatMap((a) => a.nodes).find((n) => n.id === nodeId); return arcs.flatMap((a) => a.nodes).find((n) => n.node_id === nodeId);
} }
export function getActiveArc(arcs: QuestArc[], activeArcId: string): QuestArc { export function getActiveArc(arcs: QuestArc[], activeArcId: string): QuestArc {

View File

@ -2,53 +2,59 @@
// Swap dummy data for API responses later — shape stays the same. // Swap dummy data for API responses later — shape stays the same.
export type RequirementType = export type RequirementType =
| "questions" | "QUESTIONS_ANSWERED"
| "accuracy" | "SESSIONS_COMPLETED"
| "streak" | "MIN_ACCURACY"
| "sessions" | "DAILY_STREAK"
| "topics" | "LIFETIME_XP"
| "xp" | "LEADERBOARD_RANK";
| "leaderboard";
export type NodeStatus = "locked" | "active" | "claimable" | "completed"; export type NodeStatus = "LOCKED" | "ACTIVE" | "CLAIMABLE" | "COMPLETED";
export type RewardItem = "streak_shield" | "xp_boost" | "title"; 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 { export interface QuestNode {
node_id: string;
name: string;
description: string;
sequence_order: number;
req_type: RequirementType;
req_target: number;
reward_coins: number;
reward_xp: number;
reward_title: {
name: string;
description: string;
id: string; 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) reward_items: InventoryItem[];
status: NodeStatus; status: string;
reward: QuestReward; current_value: 0;
} }
export interface QuestArc { export interface QuestArc {
id: string; id: string;
name: string; // "East Blue", "Alabasta", "Skypiea" name: string;
subtitle: string; // short flavour line description: string;
emoji: string; image_url: string;
accentColor: string; // CSS color for this arc's theme sequence_order: number;
accentDark: string;
bgFrom: string; // gradient start for arc header
bgTo: string;
nodes: QuestNode[]; nodes: QuestNode[];
} }
export type Title = {
name: string;
description: string;
id: string;
};
export interface ClaimedRewardResponse {
message: string;
xp_awarded: 0;
coins_awarded: 0;
title_unlocked: Title[];
items_awarded: InventoryItem[];
}
// ─── Crew Rank ladder (shown on profile / leaderboard) ─────────────────────── // ─── Crew Rank ladder (shown on profile / leaderboard) ───────────────────────
export const CREW_RANKS = [ export const CREW_RANKS = [
{ id: "cabin_boy", label: "Cabin Boy", emoji: "⚓", xpRequired: 0 }, { id: "cabin_boy", label: "Cabin Boy", emoji: "⚓", xpRequired: 0 },
@ -58,3 +64,44 @@ export const CREW_RANKS = [
{ id: "emperor", label: "Emperor", emoji: "👑", xpRequired: 6000 }, { id: "emperor", label: "Emperor", emoji: "👑", xpRequired: 6000 },
{ id: "pirate_king", label: "Pirate King", emoji: "🏴‍☠️", xpRequired: 10000 }, { id: "pirate_king", label: "Pirate King", emoji: "🏴‍☠️", xpRequired: 10000 },
] as const; ] as const;
export type UserTitle = {
title_id: string;
title: {
name: string;
description: string;
id: string;
};
unlocked_at: string;
is_active: false;
};
export type InventoryItem = {
id: string;
item: {
name: string;
description: string;
type: string;
effect_type: string;
effect_value: number;
id: string;
};
quantity: number;
};
export type ActiveEffect = {
id: string;
item: {
name: string;
description: string;
type: string;
effect_type: string;
effect_value: number;
id: string;
};
expires_at: string;
};
export interface UserInventory {
items: InventoryItem[];
active_effects: ActiveEffect[];
}

View File

@ -1,5 +1,11 @@
import type { Leaderboard, PredictedScore } from "../types/leaderboard"; import type { Leaderboard, PredictedScore } from "../types/leaderboard";
import type { Lesson, LessonsResponse } from "../types/lesson"; import type { Lesson, LessonsResponse } from "../types/lesson";
import type {
ClaimedRewardResponse,
QuestArc,
UserInventory,
UserTitle,
} from "../types/quest";
import type { import type {
SessionAnswerResponse, SessionAnswerResponse,
SessionQuestionsResponse, SessionQuestionsResponse,
@ -242,5 +248,46 @@ class ApiClient {
async fetchPredictedScore(token: string): Promise<PredictedScore> { async fetchPredictedScore(token: string): Promise<PredictedScore> {
return this.authenticatedRequest<PredictedScore>(`/prediction/`, token); return this.authenticatedRequest<PredictedScore>(`/prediction/`, token);
} }
/*------------QUEST JOURNEY-------------- */
async fetchUserJourney(token: string): Promise<QuestArc[]> {
return this.authenticatedRequest<QuestArc[]>(`/journey/`, token);
}
async claimReward(
token: string,
node_id: string,
): Promise<ClaimedRewardResponse> {
return this.authenticatedRequest<ClaimedRewardResponse>(
`/journey/claim/${node_id}`,
token,
{
method: "POST",
},
);
}
/*------------INVENTORY-------------- */
async fetchUserInventory(token: string): Promise<UserInventory> {
return this.authenticatedRequest<UserInventory>(`/inventory/`, token);
}
async activateItem(token: string, itemId: string): Promise<UserInventory> {
return this.authenticatedRequest<UserInventory>(
`/inventory/use/${itemId}`,
token,
);
}
/*------------TITLES-------------- */
async fetchUserTitles(token: string): Promise<UserTitle[]> {
return this.authenticatedRequest<UserTitle[]>(`/inventory/titles/`, token);
}
async equipTitle(
token: string,
titleData: { title_id: string },
): Promise<string> {
return this.authenticatedRequest<string>(`/inventory/titles/equip`, token, {
method: "POST",
body: JSON.stringify(titleData),
});
}
} }
export const api = new ApiClient(API_URL); export const api = new ApiClient(API_URL);