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 type { QuestNode } from "../types/quest";
import type { QuestNode, ClaimedRewardResponse } from "../types/quest";
// ─── Styles ───────────────────────────────────────────────────────────────────
const S = `
@ -327,6 +327,14 @@ const S = `
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 ── */
.com-cta {
width:100%; padding:1rem;
@ -369,14 +377,12 @@ const PARTICLE_COLORS = [
const COIN_EMOJIS = ["🪙", "💰", "✨", "⭐", "💎", "🌟", "💫", "🏅"];
const SPARKLE_EMOJIS = ["✨", "⭐", "💫", "🌟"];
// Rays at evenly spaced angles
const RAYS = Array.from({ length: 12 }, (_, i) => ({
id: i,
angle: `${(i / 12) * 360}deg`,
delay: `${i * 0.04}s`,
}));
// Burst rings
const BURST_RINGS = [
{ id: 0, size: "3", dur: "0.7s", delay: "0s" },
{ id: 1, size: "5", dur: "0.9s", delay: "0.1s" },
@ -384,7 +390,6 @@ const BURST_RINGS = [
{ id: 3, size: "12", dur: "1.4s", delay: "0.3s" },
];
// Stars in background — stable between renders
const STARS = Array.from({ length: 40 }, (_, i) => ({
id: i,
w: 1 + ((i * 7) % 3),
@ -394,7 +399,6 @@ const STARS = Array.from({ length: 40 }, (_, i) => ({
delay: `${(i * 7) % 3}s`,
}));
// Sparkles floating around the revealed card
const SPARKLES = Array.from({ length: 8 }, (_, i) => ({
id: i,
emoji: SPARKLE_EMOJIS[i % 4],
@ -409,10 +413,11 @@ type Phase = "idle" | "shaking" | "opening" | "revealed";
interface Props {
node: QuestNode;
claimResult: ClaimedRewardResponse | null;
onClose: () => void;
}
export const ChestOpenModal = ({ node, onClose }: Props) => {
export const ChestOpenModal = ({ node, claimResult, onClose }: Props) => {
const [phase, setPhase] = useState<Phase>("idle");
const [showXP, setShowXP] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -464,40 +469,55 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
[],
);
const rewards = [
{
key: "xp",
cls: "xp-row",
icon: "⚡",
lbl: "XP Gained",
val: `+${node.reward.xp} XP`,
delay: "0.05s",
},
...(node.reward.title
? [
{
key: "title",
cls: "",
icon: "🏴‍☠️",
lbl: "Crew Title",
val: node.reward.title,
delay: "0.15s",
},
]
: []),
...(node.reward.itemLabel
? [
{
key: "item",
cls: "",
icon: "🎁",
lbl: "Item",
val: node.reward.itemLabel,
delay: "0.25s",
},
]
: []),
];
// ── 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",
cls: "xp-row",
icon: "",
lbl: "XP Gained",
val: `+${xpAwarded} XP`,
delay: "0.05s",
},
// One row per unlocked title (usually 0 or 1)
...titlesAwarded.map((t, i) => ({
key: `title-${t.id}`,
cls: "",
icon: "🏴‍☠️",
lbl: "Crew Title",
val: t.name,
delay: `${0.1 + i * 0.1}s`,
})),
// One row per awarded item
...itemsAwarded.map((inv, i) => ({
key: `item-${inv.id}`,
cls: "",
icon: "🎁",
lbl: inv.item.type ?? "Item",
val: inv.item.name,
delay: `${0.1 + (titlesAwarded.length + i) * 0.1}s`,
})),
]
: [];
const chestClass =
phase === "idle"
@ -534,7 +554,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
/>
))}
{/* Crepuscular rays (appear on open) */}
{/* Crepuscular rays */}
{(phase === "opening" || phase === "revealed") && (
<div className="com-rays">
{RAYS.map((r) => (
@ -639,8 +659,12 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
</div>
))}
{/* XP blast */}
{showXP && <div className="com-xp-blast">+{node.reward.xp} XP</div>}
{/* XP blast — uses xp_awarded from claimResult */}
{showXP && (
<div className="com-xp-blast">
{xpAwarded > 0 ? `+${xpAwarded} XP` : "✨"}
</div>
)}
{/* Card */}
<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>
</>
)}
{phase === "shaking" && (
<>
<p className="com-shake-text">The chest stirs...</p>
<p className="com-shake-dots"> </p>
</>
)}
{phase === "revealed" && (
<>
<p className="com-rewards-title"> Spoils of Victory</p>
<div className="com-rewards">
{/* claimResult not yet available — API still in flight */}
{!claimResult && (
<p className="com-rewards-loading">
Counting your spoils...
</p>
)}
{rewards.map((r) => (
<div
key={r.key}
@ -697,7 +729,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
</div>
<button
className="com-cta"
style={{ animationDelay: rewards.length * 0.1 + "s" }}
style={{ animationDelay: `${rewards.length * 0.1}s` }}
onClick={onClose}
>
Set Sail
@ -707,7 +739,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
</div>
</div>
{/* Skip link for impatient pirates */}
{/* Skip link */}
{phase === "revealed" && (
<p className="com-skip" onClick={onClose}>
tap anywhere to continue

View File

@ -6,14 +6,41 @@ import {
useQuestStore,
getQuestSummary,
getCrewRank,
getEarnedXP,
} 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 { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
import { PredictedScoreCard } from "./PredictedScoreCard";
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 ───────────────────────────────────────────────────────────────────
const STYLES = `
@ -196,8 +223,6 @@ const STYLES = `
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
margin-bottom: 12px;
}
/* Animated sea shimmer */
.hc-ext::before {
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
background:
@ -210,16 +235,12 @@ const STYLES = `
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
/* Gold orb */
.hc-ext::after {
content: ''; position: absolute; top: -40px; right: -30px; z-index: 0;
width: 180px; height: 180px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%);
pointer-events: none;
}
/* Header */
.hc-ext-header {
position: relative; z-index: 2;
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;
padding: 0.2rem 0.6rem;
}
/* Scrollable track container */
.hc-ext-scroll {
position: relative; z-index: 2;
overflow-x: auto; overflow-y: hidden;
@ -245,26 +264,17 @@ const STYLES = `
}
.hc-ext-scroll::-webkit-scrollbar { display: none; }
.hc-ext-scroll:active { cursor: grabbing; }
/* Track inner wrapper — the thing that actually lays out rank nodes */
.hc-ext-inner {
display: flex; align-items: flex-end;
position: relative;
/* height: ship(28px) + gap(14px) + node(52px) + label(36px) = ~130px */
height: 110px;
/* width set inline per node count */
}
/* Baseline connector line — full width, dim */
.hc-ext-baseline {
position: absolute;
top: 56px; /* ship(28) + gap(14) + half of node(26) — sits at node centre */
left: 26px; right: 26px; height: 2px;
top: 56px; left: 26px; right: 26px; height: 2px;
background: rgba(255,255,255,0.07);
border-radius: 2px; z-index: 0;
}
/* Gold progress line — width set inline */
.hc-ext-progress-line {
position: absolute;
top: 56px; left: 26px; height: 2px;
@ -273,12 +283,9 @@ const STYLES = `
border-radius: 2px; z-index: 1;
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
}
/* Ship — absolutely positioned, transition on 'left' */
.hc-ext-ship-wrap {
position: absolute;
top: 25px; /* sits at top of inner, ship 28px + gap 14px = 42px to node top (56px centre) */
z-index: 10; pointer-events: none;
top: 25px; z-index: 10; pointer-events: none;
display: flex; flex-direction: column; align-items: center; gap: 0px;
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
transform: translateX(-50%);
@ -297,23 +304,18 @@ const STYLES = `
width: 1px; height: 14px;
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
}
/* Each rank column */
.hc-ext-col {
display: flex; flex-direction: column; align-items: center;
position: relative; z-index: 2;
width: 88px; flex-shrink: 0;
}
/* Narrow first/last columns so line extends correctly */
.hc-ext-col:first-child,
.hc-ext-col:last-child { width: 52px; }
/* Node circle */
.hc-ext-node {
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; position: relative; z-index: 2;
margin-top: 42px; /* push down below ship zone */
margin-top: 42px;
}
.hc-ext-node.reached {
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); }
}
.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);
filter: grayscale(0.7) opacity(0.45);
}
/* Labels below node */
.hc-ext-label {
margin-top: 7px;
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.current { color: rgba(192,132,252,0.6); }
.hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); }
/* Footer link */
.hc-ext-footer {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
@ -372,13 +370,14 @@ const STYLES = `
.hc-ext-footer:hover { opacity: 0.75; }
`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getActiveQuests(arcs: QuestArc[]) {
const out: { node: QuestNode; arc: QuestArc }[] = [];
for (const arc of arcs)
for (const node of arc.nodes)
if (node.status === "claimable" || node.status === "active")
out.push({ node, arc });
// Claimable nodes bubble to the top
out.sort((a, b) =>
a.node.status === "claimable" && b.node.status !== "claimable"
? -1
@ -389,10 +388,8 @@ function getActiveQuests(arcs: QuestArc[]) {
return out.slice(0, 2);
}
// Segment width for nodes that aren't first/last
const SEG_W = 88;
const EDGE_W = 52;
// Centre x of node at index i (0-based, total N nodes)
function nodeX(i: number, total: number): number {
if (i === 0) return 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 ────────────────────────────────────────────
const RankLadder = ({
earnedXP,
onViewAll,
}: {
earnedXP: number;
onViewAll: () => void;
@ -411,7 +407,6 @@ const RankLadder = ({
const ladder = [...CREW_RANKS] as typeof CREW_RANKS;
const N = ladder.length;
// Which rank the user is currently on (0-based)
let currentIdx = 0;
for (let i = N - 1; i >= 0; i--) {
if (earnedXP >= ladder[i].xpRequired) {
@ -430,19 +425,13 @@ const RankLadder = ({
)
: 1;
// Ship x position: interpolate between current node and next node
const shipX = nextRank
? nodeX(currentIdx, N) +
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext
: nodeX(currentIdx, N);
// Gold progress line width: from left edge to ship position
const progressLineW = shipX;
// Total scroll width
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
// Animate ship in after mount
const [animated, setAnimated] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() =>
@ -451,13 +440,13 @@ const RankLadder = ({
return () => cancelAnimationFrame(id);
}, []);
// Auto-scroll to ship position on mount
useEffect(() => {
if (!scrollRef.current) return;
const el = scrollRef.current;
const containerW = el.offsetWidth;
const targetScroll = shipX - containerW / 2;
el.scrollTo({ left: Math.max(0, targetScroll), behavior: "smooth" });
el.scrollTo({
left: Math.max(0, shipX - el.offsetWidth / 2),
behavior: "smooth",
});
}, [shipX]);
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
@ -467,13 +456,11 @@ const RankLadder = ({
return (
<div className="hc-ext">
{/* Header */}
<div className="hc-ext-header">
<span className="hc-ext-title"> Crew Rank</span>
<span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span>
</div>
{/* Current rank label */}
<div
style={{
position: "relative",
@ -507,19 +494,13 @@ const RankLadder = ({
</span>
</div>
{/* Scrollable rank track */}
<div className="hc-ext-scroll" ref={scrollRef}>
<div className="hc-ext-inner" style={{ width: totalW }}>
{/* Baseline dim line */}
<div className="hc-ext-baseline" />
{/* Gold progress line */}
<div
className="hc-ext-progress-line"
style={{ width: animated ? progressLineW : 26 }}
/>
{/* Ship marker */}
<div
className="hc-ext-ship-wrap"
style={{ left: animated ? shipX : nodeX(0, N) }}
@ -529,8 +510,6 @@ const RankLadder = ({
</span>
<div className="hc-ext-ship-tether" />
</div>
{/* Rank nodes */}
{ladder.map((r, i) => {
const state =
i < currentIdx
@ -556,19 +535,12 @@ const RankLadder = ({
})}
</div>
</div>
{/* Footer */}
{/* <div className="hc-ext-footer" onClick={onViewAll}>
<Map size={12} />
View quest map
</div> */}
</div>
);
};
// ─── Props ────────────────────────────────────────────────────────────────────
type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED";
interface Props {
onViewAll?: () => void;
mode?: Mode;
@ -578,17 +550,22 @@ interface Props {
export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
const navigate = useNavigate();
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 earnedXP = user?.total_xp ?? 0;
const earnedTitles = useQuestStore((s) => s.earnedTitles);
const claimNode = useQuestStore((s) => s.claimNode);
const summary = getQuestSummary(arcs);
const rank = getCrewRank(arcs);
const earnedXP = getEarnedXP(arcs);
// Updated signatures: getQuestSummary needs earnedXP + earnedTitles,
// getCrewRank takes earnedXP directly (no longer iterates nodes)
const summary = getQuestSummary(arcs, earnedXP, earnedTitles);
const rank = getCrewRank(earnedXP);
const activeQuests = getActiveQuests(arcs);
const u = user as any;
const level = u?.current_level ?? u?.level ?? 1;
const totalXP = u?.total_xp ?? u?.xp ?? 0;
const level = u?.current_level ?? 1;
const totalXP = u?.total_xp ?? 5;
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
const levelEnd =
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
@ -621,17 +598,31 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
node: QuestNode;
arcId: string;
} | 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 = () => {
if (onViewAll) onViewAll();
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 });
};
const handleChestClose = () => {
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);
setClaimResult(null);
};
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 showQuestExtended = mode === "QUEST_EXTENDED";
// QUEST_EXTENDED renders its own standalone dark card — no .hc-card wrapper
if (showQuestExtended) {
return (
<>
<style>{STYLES}</style>
<RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} />
{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>
</div>
</div>
<InventoryButton label="Inventory" />
<Drawer direction="top">
<DrawerTrigger asChild>
<button className="hc-score-btn">
<Gauge size={14} /> Score
<Gauge size={14} />
</button>
</DrawerTrigger>
<DrawerContent>
@ -702,6 +697,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
</DrawerContent>
</Drawer>
</div>
<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>
) : (
activeQuests.map(({ node, arc }) => {
// Progress uses new field names
const pct = Math.min(
100,
Math.round(
(node.progress / node.requirement.target) * 100,
),
Math.round((node.current_value / node.req_target) * 100),
);
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 (
<div
key={node.id}
key={node.node_id} // node_id replaces old id
className="hc-quest-row"
style={
{ "--ac": arc.accentColor } as React.CSSProperties
}
style={{ "--ac": accentColor } as React.CSSProperties}
onClick={() => !isClaimable && handleViewAll()}
>
<div
className={`hc-q-icon${isClaimable ? " claimable" : ""}`}
>
{isClaimable ? "📦" : node.emoji}
{isClaimable ? "📦" : nodeEmoji}
</div>
<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 ? (
<p className="hc-q-claimable"> Ready to claim!</p>
) : (
<p className="hc-q-sub">
{node.progress}/{node.requirement.target}{" "}
{node.requirement.label} · {pct}%
{/* current_value / req_target replace old progress / requirement.target */}
{node.current_value}/{node.req_target} {reqLabel}{" "}
· {pct}%
</p>
)}
</div>
@ -804,8 +806,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
)}
</div>
<div className="hc-map-link" onClick={handleViewAll}>
<Map size={13} />
View quest map
<Map size={13} /> View quest map
</div>
</div>
</>
@ -813,7 +814,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
</div>
{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 { 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 ───────────────────────────────────────────────────────────────────
const STYLES = `
@ -30,11 +54,9 @@ const STYLES = `
}
@keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} }
/* Handle */
.qnm-handle-row { display:flex; justify-content:center; padding:0.8rem 0 0.3rem; flex-shrink:0; }
.qnm-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); }
/* Close btn */
.qnm-close {
position:absolute; top:0.9rem; right:1.1rem; z-index:10;
width:30px; height:30px; border-radius:50%;
@ -50,8 +72,6 @@ const STYLES = `
height: 200px; overflow: hidden;
background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%);
}
/* Sea waves */
.qnm-sea {
position:absolute; bottom:0; left:0; right:0; height:52px;
background: var(--sea-col); overflow:hidden;
@ -69,25 +89,19 @@ const STYLES = `
50% { transform: translateX(15%) scaleY(1.08);}
100%{ transform: translateX(0) scaleY(1); }
}
/* Floating clouds */
.qnm-cloud {
position:absolute; border-radius:50px;
background: rgba(255,255,255,0.18);
filter: blur(4px);
background: rgba(255,255,255,0.18); filter: blur(4px);
animation: qnmDrift var(--cdur,18s) linear infinite;
}
@keyframes qnmDrift {
0% { transform: translateX(-120px); opacity:0; }
0% { transform: translateX(-120px); opacity:0; }
10% { opacity:1; }
90% { opacity:1; }
100%{ transform: translateX(calc(100vw + 120px)); opacity:0; }
}
/* ── The 3D island container ── */
.qnm-island-3d-wrap {
position: absolute;
left: 50%; bottom: 40px;
position: absolute; left: 50%; bottom: 40px;
transform: translateX(-50%);
perspective: 420px;
width: 220px; height: 140px;
@ -102,16 +116,12 @@ const STYLES = `
0% { transform: rotateX(22deg) rotateY(0deg); }
100% { transform: rotateX(22deg) rotateY(360deg); }
}
/* Island layers — stacked in 3D */
.qnm-il { /* island layer base class */
.qnm-il {
position: absolute; left: 50%; bottom: 0;
transform-origin: bottom center;
border-radius: 50%;
transform-style: preserve-3d;
}
/* Water base disc */
.qnm-il-water {
width: 200px; height: 44px; margin-left: -100px;
background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col));
@ -120,12 +130,7 @@ const STYLES = `
box-shadow: 0 0 40px var(--sea-col);
animation: qnmWaterShimmer 3s ease-in-out infinite;
}
@keyframes qnmWaterShimmer {
0%,100%{ opacity:1; }
50% { opacity:0.82; }
}
/* Ripple rings on water */
@keyframes qnmWaterShimmer { 0%,100%{ opacity:1; } 50%{ opacity:0.82; } }
.qnm-ripple {
position:absolute; left:50%; top:50%;
border-radius:50%; border:1.5px solid rgba(255,255,255,0.25);
@ -136,8 +141,6 @@ const STYLES = `
0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; }
100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; }
}
/* Island ground */
.qnm-il-ground {
width: 160px; height: 36px; margin-left: -80px;
background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo));
@ -145,8 +148,6 @@ const STYLES = `
transform: translateZ(14px);
box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25);
}
/* Island side face — gives the 3D depth illusion */
.qnm-il-side {
width: 158px; height: 22px; margin-left: -79px;
bottom: -12px;
@ -154,8 +155,6 @@ const STYLES = `
clip-path: ellipse(79px 100% at 50% 0%);
transform: translateZ(8px) rotateX(-8deg);
}
/* Peak */
.qnm-il-peak {
width: 80px; height: 60px; margin-left: -40px;
bottom: 26px;
@ -169,28 +168,18 @@ const STYLES = `
0%,100%{ transform: translateZ(26px) translateY(0); }
50% { transform: translateZ(26px) translateY(-4px); }
}
/* Floating decoration layer (trees, cactus, cloud orb, etc.) */
.qnm-il-deco {
position: absolute; bottom: 56px; left: 50%;
transform: translateZ(42px);
animation: qnmDecoFloat 3s ease-in-out infinite;
}
@keyframes qnmDecoFloat {
0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); }
50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); }
0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); }
50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); }
}
.qnm-deco-emoji { font-size:1.4rem; filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5)); }
/* Flag pole on active */
.qnm-il-flag {
position:absolute; bottom:56px; left:50%;
transform: translateZ(50px) translateX(12px);
}
.qnm-flag-pole {
width:2px; height:26px; background:#7c4a1e;
border-radius:2px;
}
.qnm-il-flag { position:absolute; bottom:56px; left:50%; transform: translateZ(50px) translateX(12px); }
.qnm-flag-pole { width:2px; height:26px; background:#7c4a1e; border-radius:2px; }
.qnm-flag-cloth {
position:absolute; top:2px; left:2px;
width:16px; height:11px;
@ -198,19 +187,14 @@ const STYLES = `
animation: qnmFlagWave 1.2s ease-in-out infinite;
transform-origin:left center;
}
@keyframes qnmFlagWave {
0%,100%{ transform:skewY(0deg); }
50% { transform:skewY(-10deg); }
}
/* Stars / sparkles above completed island */
@keyframes qnmFlagWave { 0%,100%{ transform:skewY(0deg); } 50%{ transform:skewY(-10deg); } }
.qnm-star {
position:absolute; font-size:1rem;
animation: qnmStarPop var(--sdur,2s) ease-in-out infinite;
animation-delay: var(--sdel,0s);
}
@keyframes qnmStarPop {
0%,100%{ transform:scale(1) translateY(0); opacity:0.8; }
0%,100%{ transform:scale(1) translateY(0); opacity:0.8; }
50% { transform:scale(1.4) translateY(-8px); opacity:1; }
}
@ -221,8 +205,6 @@ const STYLES = `
padding:1.1rem 1.25rem 0.5rem;
}
.qnm-body::-webkit-scrollbar { display:none; }
/* Title block */
.qnm-title-block { position:relative; }
.qnm-arc-tag {
display:inline-flex; align-items:center; gap:0.3rem;
@ -240,8 +222,6 @@ const STYLES = `
font-family:'Nunito Sans',sans-serif;
font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38);
}
/* Flavour quote */
.qnm-flavour {
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
border-left:3px solid var(--ac);
@ -253,8 +233,6 @@ const STYLES = `
font-size:0.82rem; color:rgba(255,255,255,0.55);
font-style:italic; line-height:1.6;
}
/* Objective card */
.qnm-obj-card {
background:rgba(255,255,255,0.04);
border:1px solid rgba(255,255,255,0.08);
@ -271,9 +249,7 @@ const STYLES = `
font-family:'Nunito',sans-serif;
font-size:0.78rem; font-weight:900; color:var(--ac);
}
.qnm-obj-row {
display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem;
}
.qnm-obj-row { display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem; }
.qnm-obj-icon {
width:38px; height:38px; border-radius:12px; flex-shrink:0;
background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08);
@ -287,8 +263,6 @@ const STYLES = `
font-family:'Nunito Sans',sans-serif;
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem;
}
/* Progress bar */
.qnm-bar-track {
height:9px; background:rgba(255,255,255,0.07);
border-radius:100px; overflow:hidden; margin-bottom:0.3rem;
@ -305,21 +279,16 @@ const STYLES = `
font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28);
}
.qnm-bar-nums span:first-child { color:var(--ac); }
/* ── HOW TO COMPLETE section ── */
.qnm-howto-label {
font-size:0.58rem; font-weight:800; letter-spacing:0.14em;
text-transform:uppercase; color:rgba(255,255,255,0.3);
margin-bottom:0.55rem; margin-top:0.3rem;
}
.qnm-howto-badges {
display:flex; flex-wrap:wrap; gap:0.4rem;
}
.qnm-howto-badges { display:flex; flex-wrap:wrap; gap:0.4rem; }
.qnm-howto-badge {
display:flex; align-items:center; gap:0.3rem;
padding:0.38rem 0.75rem;
background:rgba(255,255,255,0.06);
border:1px solid rgba(255,255,255,0.1);
background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1);
border-radius:100px;
font-family:'Nunito',sans-serif;
font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7);
@ -332,19 +301,14 @@ const STYLES = `
to { opacity:1; transform:scale(1) translateY(0); }
}
.qnm-howto-badge:hover {
background:rgba(255,255,255,0.1);
border-color:rgba(255,255,255,0.2);
color:white;
transform:translateY(-1px);
background:rgba(255,255,255,0.1); border-color:rgba(255,255,255,0.2);
color:white; transform:translateY(-1px);
}
/* Highlight badge = accent coloured */
.qnm-howto-badge.hi {
background:color-mix(in srgb, var(--ac) 18%, transparent);
border-color:color-mix(in srgb, var(--ac) 45%, transparent);
color:var(--ac);
}
/* Locked banner */
.qnm-locked-banner {
display:flex; align-items:center; gap:0.7rem;
background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07);
@ -362,11 +326,8 @@ const STYLES = `
font-family:'Nunito Sans',sans-serif;
font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem;
}
/* Reward card */
.qnm-reward-card {
background:rgba(251,191,36,0.07);
border:1px solid rgba(251,191,36,0.22);
background:rgba(251,191,36,0.07); border:1px solid rgba(251,191,36,0.22);
border-radius:18px; padding:0.9rem 1rem;
}
.qnm-reward-label {
@ -409,71 +370,12 @@ const STYLES = `
}
`;
// ─── Per-arc terrain themes ───────────────────────────────────────────────────
interface Terrain {
skyTop: string;
skyBot: string;
seaCol: string;
seaHi: string;
terrHi: string;
terrMid: string;
terrLo: string;
peakHi: string;
peakMid: string;
peakLo: string;
decos: string[];
}
const TERRAIN: Record<string, Terrain> = {
east_blue: {
skyTop: "#0a1628",
skyBot: "#0d2240",
seaCol: "#0a3d5c",
seaHi: "#1a6a8a",
terrHi: "#5eead4",
terrMid: "#0d9488",
terrLo: "#0f5c55",
peakHi: "#a7f3d0",
peakMid: "#34d399",
peakLo: "#065f46",
decos: ["🌴", "🌿"],
},
alabasta: {
skyTop: "#1c0a00",
skyBot: "#3d1a00",
seaCol: "#7c3a00",
seaHi: "#c26010",
terrHi: "#fde68a",
terrMid: "#d97706",
terrLo: "#78350f",
peakHi: "#fef3c7",
peakMid: "#fbbf24",
peakLo: "#92400e",
decos: ["🌵", "🏺"],
},
skypiea: {
skyTop: "#1a0033",
skyBot: "#2e0050",
seaCol: "#4c1d95",
seaHi: "#7c3aed",
terrHi: "#e9d5ff",
terrMid: "#a855f7",
terrLo: "#581c87",
peakHi: "#f5d0fe",
peakMid: "#d946ef",
peakLo: "#701a75",
decos: ["☁️", "✨"],
},
};
const DEFAULT_TERRAIN = TERRAIN.east_blue;
// ─── Per-requirement how-to badges ───────────────────────────────────────────
// ─── How-to badges ────────────────────────────────────────────────────────────
interface Badge {
emoji: string;
label: string;
highlight?: boolean;
}
const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
questions: {
title: "How to complete this",
@ -540,12 +442,7 @@ const HOW_TO: Record<string, { title: string; badges: Badge[] }> = {
},
};
// ─── Island shape configs (mirrors the 6 clip-path shapes in QuestMap) ────────
// groundClip = clip-path for the flat top disc of the island
// peakClip = clip-path for the hill/feature rising above it
// groundW/H = pixel size of the ground layer
// peakW/H = pixel size of the peak layer
// sideClip = clip-path for the side-face depth layer
// ─── Island shape configs (mirrors QuestMap SHAPES[0..5]) ─────────────────────
interface ShapeConfig {
groundClip: string;
peakClip: string;
@ -554,12 +451,9 @@ interface ShapeConfig {
groundH: number;
peakW: 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[] = [
// 0: fat round atoll
{
groundClip: "ellipse(50% 50% at 50% 50%)",
peakClip: "ellipse(50% 50% at 50% 55%)",
@ -570,7 +464,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 38,
peakBottom: 26,
},
// 1: tall mountain — narrow diamond ground, sharp triangular peak
{
groundClip: "polygon(50% 5%, 92% 50%, 50% 95%, 8% 50%)",
peakClip: "polygon(50% 0%, 82% 52%, 100% 100%, 0% 100%, 18% 52%)",
@ -581,7 +474,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 72,
peakBottom: 24,
},
// 2: wide flat shoal — extra-wide squashed ellipse, low dome
{
groundClip: "ellipse(50% 40% at 50% 58%)",
peakClip: "ellipse(50% 38% at 50% 60%)",
@ -592,7 +484,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 28,
peakBottom: 22,
},
// 3: jagged rocky reef — star-burst polygon
{
groundClip:
"polygon(50% 2%, 63% 35%, 98% 35%, 71% 56%, 80% 92%, 50% 72%, 20% 92%, 29% 56%, 2% 35%, 37% 35%)",
@ -605,7 +496,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 66,
peakBottom: 24,
},
// 4: crescent — lopsided asymmetric bean
{
groundClip:
"path('M 80 10 C 120 5, 150 30, 145 55 C 140 78, 110 88, 80 85 C 55 82, 38 70, 42 55 C 46 42, 62 40, 68 50 C 74 60, 65 70, 55 68 C 38 62, 30 42, 42 28 C 55 12, 70 12, 80 10 Z')",
@ -617,7 +507,6 @@ const ISLAND_SHAPES: ShapeConfig[] = [
peakH: 58,
peakBottom: 22,
},
// 5: teardrop/pear — narrow top, wide rounded base
{
groundClip:
"path('M 50 4 C 72 4, 95 28, 95 55 C 95 78, 76 94, 50 94 C 24 94, 5 78, 5 55 C 5 28, 28 4, 50 4 Z')",
@ -632,29 +521,104 @@ const ISLAND_SHAPES: ShapeConfig[] = [
},
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
const reqIcon = (type: string): string =>
({
questions: "❓",
accuracy: "🎯",
streak: "🔥",
sessions: "📚",
topics: "🗺️",
xp: "⚡",
leaderboard: "🏆",
})[type] ?? "⭐";
// ─── Terrain type (mirrors ArcTheme.terrain from QuestMap) ────────────────────
interface StageTerrain {
skyTop: string;
skyBot: string;
seaCol: string;
seaHi: string;
terrHi: string;
terrMid: string;
terrLo: string;
peakHi: string;
peakMid: string;
peakLo: string;
decos: string[];
}
/**
* Converts the ArcTheme colours produced by generateArcTheme into the
* StageTerrain shape the 3D stage needs. For the three known arcs we keep
* hand-tuned sky/sea values; for unknown arcs we derive them from the theme.
*/
const KNOWN_STAGE_TERRAIN: Record<string, StageTerrain> = {
east_blue: {
skyTop: "#0a1628",
skyBot: "#0d2240",
seaCol: "#0a3d5c",
seaHi: "#1a6a8a",
terrHi: "#5eead4",
terrMid: "#0d9488",
terrLo: "#0f5c55",
peakHi: "#a7f3d0",
peakMid: "#34d399",
peakLo: "#065f46",
decos: ["🌴", "🌿"],
},
alabasta: {
skyTop: "#1c0a00",
skyBot: "#3d1a00",
seaCol: "#7c3a00",
seaHi: "#c26010",
terrHi: "#fde68a",
terrMid: "#d97706",
terrLo: "#78350f",
peakHi: "#fef3c7",
peakMid: "#fbbf24",
peakLo: "#92400e",
decos: ["🌵", "🏺"],
},
skypiea: {
skyTop: "#1a0033",
skyBot: "#2e0050",
seaCol: "#4c1d95",
seaHi: "#7c3aed",
terrHi: "#e9d5ff",
terrMid: "#a855f7",
terrLo: "#581c87",
peakHi: "#f5d0fe",
peakMid: "#d946ef",
peakLo: "#701a75",
decos: ["☁️", "✨"],
},
};
/** Derive a StageTerrain from a generated arc theme for unknown arc ids. */
const terrainFromTheme = (arcId: string, arc: QuestArc): StageTerrain => {
if (KNOWN_STAGE_TERRAIN[arcId]) return KNOWN_STAGE_TERRAIN[arcId];
const theme = generateArcTheme(arc);
return {
// Sky: very dark version of the theme bg colours
skyTop: theme.bgFrom,
skyBot: theme.bgTo,
// Sea: use accentDark as the deep sea colour, accent as the highlight
seaCol: theme.accentDark,
seaHi: theme.accent,
// Terrain: map terrain colours directly
terrHi: theme.terrain.l,
terrMid: theme.terrain.m,
terrLo: theme.terrain.d,
// Peak: lighten accent for highlights, use terrain dark for shadow
peakHi: theme.accent,
peakMid: theme.terrain.m,
peakLo: theme.terrain.d,
decos: theme.decos.slice(0, 2),
};
};
// ─── 3D Island Stage ──────────────────────────────────────────────────────────
const IslandStage = ({
arc,
arcId,
status,
nodeIndex,
}: {
arc: QuestArc;
arcId: string;
status: QuestNode["status"];
status: string;
nodeIndex: number;
}) => {
const t = TERRAIN[arcId] ?? DEFAULT_TERRAIN;
const t = terrainFromTheme(arcId, arc);
const shp = ISLAND_SHAPES[nodeIndex % ISLAND_SHAPES.length];
const isCompleted = status === "completed";
@ -715,7 +679,7 @@ const IslandStage = ({
/>
</div>
{/* Ripple rings on water surface */}
{/* Ripple rings */}
<div
style={{
position: "absolute",
@ -737,15 +701,9 @@ const IslandStage = ({
>
<div
className="qnm-island-3d"
style={{
// Pause rotation when locked
animationPlayState: isLocked ? "paused" : "running",
}}
style={{ animationPlayState: isLocked ? "paused" : "running" }}
>
{/* Water base */}
<div className="qnm-il qnm-il-water" />
{/* Island side face */}
<div
className="qnm-il qnm-il-side"
style={{
@ -754,8 +712,6 @@ const IslandStage = ({
clipPath: shp.sideClip,
}}
/>
{/* Island ground — shaped to match QuestMap */}
<div
className="qnm-il qnm-il-ground"
style={{
@ -767,7 +723,6 @@ const IslandStage = ({
}}
/>
{/* Peak / hill — shaped to match QuestMap */}
{!isLocked && (
<div
className="qnm-il qnm-il-peak"
@ -796,15 +751,12 @@ const IslandStage = ({
</div>
))}
{/* Pirate flag on active */}
{isActive && (
<div className="qnm-il-flag">
<div className="qnm-flag-pole" />
<div className="qnm-flag-cloth" />
</div>
)}
{/* Chest bouncing on claimable */}
{isClaimable && (
<div className="qnm-il-deco" style={{ marginLeft: "-12px" }}>
<span
@ -818,8 +770,6 @@ const IslandStage = ({
</span>
</div>
)}
{/* Lock icon on locked */}
{isLocked && (
<div
style={{
@ -838,31 +788,28 @@ const IslandStage = ({
</div>
{/* Sparkles for completed */}
{isCompleted && (
<>
{[
{ left: "30%", top: "18%", sdur: "2s", sdel: "0s" },
{ left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" },
{ left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" },
{ left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" },
].map((s, i) => (
<span
key={i}
className="qnm-star"
style={
{
left: s.left,
top: s.top,
"--sdur": s.sdur,
"--sdel": s.sdel,
} as React.CSSProperties
}
>
</span>
))}
</>
)}
{isCompleted &&
[
{ left: "30%", top: "18%", sdur: "2s", sdel: "0s" },
{ left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" },
{ left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" },
{ left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" },
].map((s, i) => (
<span
key={i}
className="qnm-star"
style={
{
left: s.left,
top: s.top,
"--sdur": s.sdur,
"--sdel": s.sdel,
} as React.CSSProperties
}
>
</span>
))}
{/* Lock overlay tint */}
{isLocked && (
@ -886,6 +833,7 @@ const IslandStage = ({
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
node: QuestNode;
arc: QuestArc; // full arc object needed for theme generation
arcAccent: string;
arcDark: string;
arcId?: string;
@ -896,6 +844,7 @@ interface Props {
export const QuestNodeModal = ({
node,
arc,
arcAccent,
arcDark,
arcId = "east_blue",
@ -908,15 +857,19 @@ export const QuestNodeModal = ({
setMounted(true);
}, []);
// ── New field names ──────────────────────────────────────────────────────
const progress = Math.min(
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 isLocked = node.status === "locked";
const isCompleted = node.status === "completed";
const isActive = node.status === "active";
const howTo = HOW_TO[node.requirement.type];
return (
<div
@ -934,24 +887,32 @@ export const QuestNodeModal = ({
<X size={13} color="rgba(255,255,255,0.5)" />
</button>
{/* 3D island stage */}
<IslandStage arcId={arcId} status={node.status} nodeIndex={nodeIndex} />
{/* 3D island stage — now receives full arc for theme generation */}
<IslandStage
arc={arc}
arcId={arcId}
status={node.status}
nodeIndex={nodeIndex}
/>
{/* Scrollable content */}
<div className="qnm-body">
{/* Title */}
{/* Title block */}
<div className="qnm-title-block">
<div className="qnm-arc-tag">
{reqIcon(node.requirement.type)} Quest
</div>
<h2 className="qnm-quest-title">{node.title}</h2>
<p className="qnm-island-name">📍 {node.islandName}</p>
{/* req_type replaces node.requirement.type */}
<div className="qnm-arc-tag">{reqIcon(node.req_type)} Quest</div>
{/* node.name replaces node.title */}
<h2 className="qnm-quest-title">{node.name ?? "—"}</h2>
{/* node.islandName removed — reuse node.name as location label */}
<p className="qnm-island-name">📍 {node.name ?? "—"}</p>
</div>
{/* Flavour */}
<div className="qnm-flavour">
<p className="qnm-flavour-text">{node.flavourText}</p>
</div>
{/* Flavour — node.description replaces node.flavourText */}
{node.description && (
<div className="qnm-flavour">
<p className="qnm-flavour-text">{node.description}</p>
</div>
)}
{/* Objective */}
<div className="qnm-obj-card">
@ -964,19 +925,18 @@ export const QuestNodeModal = ({
)}
</div>
<div className="qnm-obj-row">
<div className="qnm-obj-icon">
{reqIcon(node.requirement.type)}
</div>
<div className="qnm-obj-icon">{reqIcon(node.req_type)}</div>
<div>
{/* req_target + derived label replace node.requirement.target/label */}
<p className="qnm-obj-text">
{node.requirement.target} {node.requirement.label}
{node.req_target} {reqLabel}
</p>
<p className="qnm-obj-sub">
{isCompleted
? "✅ Completed — treasure claimed!"
: isLocked
? "🔒 Complete previous quests first"
: `${node.progress} / ${node.requirement.target} done`}
: `${node.current_value} / ${node.req_target} done`}
</p>
</div>
</div>
@ -990,14 +950,15 @@ export const QuestNodeModal = ({
style={{ width: mounted ? `${progress}%` : "0%" }}
/>
</div>
{/* current_value / req_target replace old progress / requirement.target */}
<div className="qnm-bar-nums">
<span>{node.progress}</span>
<span>{node.requirement.target}</span>
<span>{node.current_value}</span>
<span>{node.req_target}</span>
</div>
</>
)}
{/* How-to badges — show when active or claimable */}
{/* How-to badges */}
{(isActive || isClaimable) && howTo && (
<>
<p className="qnm-howto-label" style={{ marginTop: "0.75rem" }}>
@ -1036,19 +997,26 @@ export const QuestNodeModal = ({
</div>
)}
{/* Reward */}
{/* Reward — sources from flat node reward fields */}
<div className="qnm-reward-card">
<p className="qnm-reward-label">📦 Treasure Chest</p>
<div className="qnm-reward-row">
<div className="qnm-reward-pill"> +{node.reward.xp} XP</div>
{node.reward.title && (
<div className="qnm-reward-pill">🏴 {node.reward.title}</div>
{/* reward_coins replaces node.reward.xp */}
{node.reward_coins > 0 && (
<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">
🎁 {node.reward.itemLabel}
🏴 {node.reward_title.name}
</div>
)}
{/* reward_items is now an array — show one pill per item */}
{node.reward_items?.map((inv) => (
<div key={inv.id} className="qnm-reward-pill">
🎁 {inv.item.name}
</div>
))}
</div>
</div>
</div>
@ -1064,9 +1032,9 @@ export const QuestNodeModal = ({
) : isLocked ? (
<p className="qnm-note">🔒 Locked keep sailing</p>
) : (
/* remaining replaces node.requirement.target - node.progress */
<p className="qnm-note">
{progress}% complete · {node.requirement.target - node.progress}{" "}
{node.requirement.label} remaining
{progress}% complete · {remaining} {reqLabel} remaining
</p>
)}
</div>

View File

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

View File

@ -1,57 +1,53 @@
import { useState, useRef } from "react";
import { Lock, CheckCircle } from "lucide-react";
import type { QuestArc, QuestNode, NodeStatus } from "../../types/quest";
import { useQuestStore, getQuestSummary } from "../../stores/useQuestStore";
import { useState, useRef, useEffect, useCallback } from "react";
import type {
QuestArc,
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 { ChestOpenModal } from "../../components/ChestOpenModal";
import { InfoHeader } from "../../components/InfoHeader";
// ─── Map geometry (all in SVG user-units, viewBox width = 390) ───────────────
const VW = 390; // viewBox width — matches typical phone width
const ROW_GAP = 260; // vertical distance between island centres
const TOP_PAD = 80; // y of first island centre
const VW = 390;
const ROW_GAP = 265;
const TOP_PAD = 80;
// Three column x-centres: Left=22%, Centre=50%, Right=78%
const COL_X = [
Math.round(VW * 0.22), // 86
Math.round(VW * 0.5), // 195
Math.round(VW * 0.78), // 304
];
// Per-arc column sequences — each arc winds differently across the map.
// 0 = Left (22%), 1 = Centre (50%), 2 = Right (78%)
const ARC_COL_SEQS: Record<string, number[]> = {
east_blue: [0, 1, 2, 0, 1, 2], // steady L→C→R march
alabasta: [2, 0, 2, 1, 0, 2], // sharp zigzag, heavy right bias
skypiea: [1, 2, 0, 2, 0, 1], // wide sweeping swings C→R→L→R→L→C
east_blue: [0, 1, 2, 0, 1, 2],
alabasta: [2, 0, 2, 1, 0, 2],
skypiea: [1, 2, 0, 2, 0, 1],
};
const COL_SEQ_DEFAULT = [0, 1, 2, 0, 1, 2];
// Card half-width / half-height for the foreign-object card
const CARD_W = 130;
const CARD_H = 195;
const 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 seq = ARC_COL_SEQS[arcId] ?? COL_SEQ_DEFAULT;
return COL_X[seq[i % seq.length]];
};
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
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) ───────────────
// ─── Island shapes ────────────────────────────────────────────────────────────
const SHAPES = [
// 0: fat round atoll
`<ellipse cx="0" cy="0" rx="57" ry="33"/>`,
// 1: tall mountain peak
`<polygon points="0,-38 28,-14 48,10 40,33 22,38 -22,38 -40,33 -48,10 -28,-14"/>`,
// 2: wide flat shoal
`<ellipse cx="0" cy="5" rx="62" ry="26"/>`,
// 3: jagged rocky reef
`<polygon points="0,-38 20,-14 50,-8 32,12 42,36 16,24 0,38 -16,24 -42,36 -32,12 -50,-8 -20,-14"/>`,
// 4: crescent (right side bites in)
`<path d="M-50,0 C-50,-34 -20,-38 0,-36 C22,-34 48,-18 50,4 C52,24 36,30 18,24 C6,20 4,10 10,4 C16,-4 26,-4 28,4 C30,12 22,18 12,16 C-4,10 -10,-8 0,-20 C12,-32 -30,-28 -50,0 Z"/>`,
// 5: teardrop/pear
`<path d="M0,-38 C18,-38 44,-18 44,8 C44,28 26,38 0,38 C-26,38 -44,28 -44,8 C-44,-18 -18,-38 0,-38 Z"/>`,
];
@ -270,33 +266,123 @@ const STYLES = `
.qm-island-in { animation: qmIslandIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both; }
`;
// ─── Data ─────────────────────────────────────────────────────────────────────
const TERRAIN: Record<string, { l: string; m: string; d: string; s: string }> =
{
east_blue: {
l: "#5eead4",
m: "#0d9488",
d: "#0f766e",
s: "rgba(13,148,136,0.55)",
},
alabasta: {
l: "#fcd34d",
m: "#d97706",
d: "#92400e",
s: "rgba(146,64,14,0.65)",
},
skypiea: {
l: "#d8b4fe",
m: "#9333ea",
d: "#6b21a8",
s: "rgba(107,33,168,0.55)",
},
// ─── Arc theme generator ──────────────────────────────────────────────────────
// 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.
export interface ArcTheme {
accent: string; // bright highlight colour
accentDark: string; // darker variant for shadows/gradients
bgFrom: string; // banner gradient start
bgTo: string; // banner gradient end
emoji: string; // banner / tab icon
terrain: { l: string; m: string; d: string; s: string }; // island fill colours
decos: [string, string, string]; // SVG decoration emojis
}
/** Cheap seeded PRNG — Mulberry32. Returns a function that yields [0,1) floats. */
const mkRng = (seed: number) => {
let s = seed >>> 0;
return () => {
s += 0x6d2b79f5;
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> = {
questions: "❓",
accuracy: "🎯",
@ -306,6 +392,30 @@ const REQ_ICON: Record<string, string> = {
xp: "⚡",
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) => ({
id: i,
w: 10 + ((i * 17 + 7) % 24),
@ -314,14 +424,24 @@ const FOAM = Array.from({ length: 22 }, (_, i) => ({
dur: `${4 + ((i * 7) % 7)}s`,
delay: `${(i * 3) % 5}s`,
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
const completedCount = (arc: QuestArc) =>
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 ──────────────────────────────────────────────────────────
const IslandNode = ({
node,
arcId,
accent,
terrain,
decos,
userXp,
index,
cx,
cy,
@ -329,24 +449,27 @@ const IslandNode = ({
onClaim,
}: {
node: QuestNode;
arcId: string;
accent: string;
terrain: ArcTheme["terrain"];
decos: ArcTheme["decos"];
userXp: number;
index: number;
cx: number;
cy: number;
onTap: (n: QuestNode) => void;
onClaim: (n: QuestNode) => void;
}) => {
const terrain = TERRAIN[arcId] ?? TERRAIN.east_blue;
const decos = DECOS[arcId] ?? DECOS.east_blue;
// node.status is typed as string from the API; normalise to expected literals
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";
const isClaimable = node.status === "claimable";
const isActive = node.status === "active";
const isLocked = node.status === "locked";
// Progress percentage — uses new current_value / req_target fields
const pct = Math.min(
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;
@ -354,14 +477,17 @@ const IslandNode = ({
const loC = isLocked ? "#1f2937" : isCompleted ? "#065f46" : terrain.d;
const shdC = isLocked ? "rgba(0,0,0,0.5)" : terrain.s;
const gradId = `grad-${node.id}`;
const clipId = `clip-${node.id}`;
const shadowId = `shadow-${node.id}`;
const glowId = `glow-${node.id}`;
const gradId = `grad-${node.node_id}`;
const clipId = `clip-${node.node_id}`;
const shadowId = `shadow-${node.node_id}`;
const glowId = `glow-${node.node_id}`;
const shapeIdx = index % SHAPES.length;
const LAND_H = 38;
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
? "is-claimable"
@ -371,6 +497,9 @@ const IslandNode = ({
? "is-locked"
: "is-completed";
// Derive island emoji from req_type (replaces old node.emoji field)
const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️";
return (
<g
style={{ cursor: isLocked ? "default" : "pointer" }}
@ -546,7 +675,7 @@ const IslandNode = ({
</text>
)}
{/* Quest emoji */}
{/* Node emoji — derived from req_type */}
{!isLocked && (
<text
x={cx}
@ -556,7 +685,7 @@ const IslandNode = ({
dominantBaseline="middle"
style={{ filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.5))" }}
>
{node.emoji}
{nodeEmoji}
</text>
)}
@ -575,7 +704,7 @@ const IslandNode = ({
</g>
)}
{/* Island name label */}
{/* Island name label — uses node.name (truncated) */}
<text
x={cx}
y={cy + LAND_H + 10}
@ -586,7 +715,7 @@ const IslandNode = ({
textAnchor="middle"
letterSpacing="0.1em"
>
{node.islandName?.toUpperCase()}
{truncate(node.name).toUpperCase()}
</text>
{/* Info card via foreignObject */}
@ -594,7 +723,7 @@ const IslandNode = ({
x={cx - CARD_W / 2}
y={cardTop}
width={CARD_W}
height={CARD_H}
height={cardH}
style={{ overflow: "visible" }}
onClick={(e) => e.stopPropagation()}
>
@ -604,34 +733,42 @@ const IslandNode = ({
onClick={() => !isLocked && onTap(node)}
>
<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">
<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>
{(isActive || isClaimable) && (
<>
<div className="qm-prog-track">
<div className="qm-prog-fill" style={{ width: `${pct}%` }} />
</div>
{/* req_type + current_value / req_target + derived label */}
<p className="qm-prog-label">
{REQ_ICON[node.requirement.type]}&nbsp;
{node.progress}/{node.requirement.target}{" "}
{node.requirement.label}
{REQ_ICON[node.req_type]}&nbsp;
{node.current_value}/{node.req_target}&nbsp;
{REQ_LABEL[node.req_type] ?? node.req_type}
</p>
</>
)}
{isLocked && (
<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>
)}
{isCompleted && (
<p className="qm-prog-label" style={{ color: "#4ade80" }}>
Conquered!
</p>
)}
{isClaimable && (
<button
className="qm-claim-btn"
@ -747,37 +884,206 @@ const RoutePath = ({
// ─── Main ─────────────────────────────────────────────────────────────────────
export const QuestMap = () => {
// ── Store — select ONLY stable primitives/actions, never derived functions ──
// ── Store ──
const arcs = useQuestStore((s) => s.arcs);
const activeArcId = useQuestStore((s) => s.activeArcId);
const setActiveArc = useQuestStore((s) => s.setActiveArc);
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
const summary = getQuestSummary(arcs);
// ── Fetch state ──
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
// ── Local UI state (doesn't need to be global) ──
const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);
// ── Claim state ──
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);
// ── Fetch journey on mount ────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
let cancelled = false;
const fetchJourney = async () => {
try {
setLoading(true);
setFetchError(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);
}
};
fetchJourney();
return () => {
cancelled = true;
};
}, [token, syncFromAPI]);
// ── Derived ───────────────────────────────────────────────────────────────
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];
const done = completedCount(arc);
const pct = Math.round((done / arc.nodes.length) * 100);
const theme = arc ? getArcTheme(arc) : null;
const done = arc ? completedCount(arc) : 0;
const pct = arc ? Math.round((done / arc.nodes.length) * 100) : 0;
const handleClaim = (node: QuestNode) => setClaimingNode(node);
const handleChestClose = () => {
// ── 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;
claimNode(arc.id, claimingNode.id); // store handles state update + next unlock
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]);
const nodes = arc.nodes;
const centres = nodes.map((_, i) => ({
// ── 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),
y: islandCY(i),
}));
const totalSvgH = svgHeight(nodes.length);
const totalSvgH = svgHeight(sorted.length);
return (
<div className="qm-screen">
@ -785,48 +1091,29 @@ export const QuestMap = () => {
{/* 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" />
<div className="qm-arc-tabs">
{arcs.map((a) => (
<button
key={a.id}
className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`}
style={{ "--arc-accent": a.accentColor } as React.CSSProperties}
onClick={() => {
setActiveArc(a.id);
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
>
{a.emoji} {a.name}
{a.nodes.some((n) => n.status === "claimable") && (
<span className="qm-tab-dot" />
)}
</button>
))}
{[...arcs]
.sort((a, b) => a.sequence_order - b.sequence_order)
.map((a) => {
const t = getArcTheme(a);
return (
<button
key={a.id}
className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`}
style={{ "--arc-accent": t.accent } as React.CSSProperties}
onClick={() => {
setActiveArc(a.id);
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
>
{t.emoji} {a.name}
{a.nodes.some((n) => n.status === "CLAIMABLE") && (
<span className="qm-tab-dot" />
)}
</button>
);
})}
</div>
</div>
@ -855,12 +1142,12 @@ export const QuestMap = () => {
<div
className="qm-arc-banner"
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-sub">{arc.subtitle}</p>
<p className="qm-arc-banner-sub">{arc.description}</p>
<div className="qm-arc-banner-prog">
<div className="qm-arc-banner-track">
<div
@ -874,21 +1161,20 @@ export const QuestMap = () => {
</div>
</div>
{/* ── Single SVG canvas for the whole map ── */}
{/* SVG map canvas */}
<svg
className="qm-map-svg"
viewBox={`0 0 ${VW} ${totalSvgH}`}
height={totalSvgH}
preserveAspectRatio="xMidYMin meet"
>
{/* Routes drawn FIRST (behind islands) */}
{nodes.map((node, i) => {
if (i >= nodes.length - 1) return null;
{sorted.map((node, i) => {
if (i >= sorted.length - 1) return null;
const c1 = centres[i];
const c2 = centres[i + 1];
const ship =
node.status === "completed" &&
nodes[i + 1]?.status === "active";
sorted[i + 1]?.status === "active";
return (
<RoutePath
key={`route-${i}`}
@ -897,19 +1183,20 @@ export const QuestMap = () => {
x2={c2.x}
y2={c2.y}
done={node.status === "completed"}
accent={arc.accentColor}
accent={theme!.accent}
showShip={ship}
/>
);
})}
{/* Islands drawn on top */}
{nodes.map((node, i) => (
{sorted.map((node, i) => (
<IslandNode
key={node.id}
key={node.node_id}
node={node}
arcId={arc.id}
accent={arc.accentColor}
accent={theme!.accent}
terrain={theme!.terrain}
decos={theme!.decos}
userXp={userXp}
index={i}
cx={centres[i].x}
cy={centres[i].y}
@ -918,8 +1205,7 @@ export const QuestMap = () => {
/>
))}
{/* Arc complete seal */}
{done === nodes.length && (
{done === sorted.length && (
<g transform={`translate(${VW / 2},${totalSvgH - 60})`}>
<circle
r="42"
@ -979,10 +1265,11 @@ export const QuestMap = () => {
{selectedNode && (
<QuestNodeModal
node={selectedNode}
arcAccent={arc.accentColor}
arcDark={arc.accentDark}
arc={arc}
arcAccent={theme!.accent}
arcDark={theme!.accentDark}
arcId={arc.id}
nodeIndex={arc.nodes.findIndex((n) => n.id === selectedNode.id)}
nodeIndex={selectedNode.sequence_order}
onClose={() => setSelectedNode(null)}
onClaim={() => {
setSelectedNode(null);
@ -990,8 +1277,38 @@ export const QuestMap = () => {
}}
/>
)}
{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>
);

View File

@ -261,7 +261,7 @@ const TARGETED_XP = 15;
const TARGETED_SCORE = 15;
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
const { userMetrics, setUserMetrics } = useExamConfigStore();
const { userMetrics } = useExamConfigStore();
const previousXP = userMetrics.xp ?? 0;
const gainedXP = TARGETED_XP;
const levelMinXP = Math.floor(previousXP / 100) * 100;
@ -269,14 +269,6 @@ const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
const currentLevel = Math.floor(previousXP / 100) + 1;
const displayXP = useCountUp(gainedXP);
useEffect(() => {
setUserMetrics({
xp: previousXP,
questions: 0,
streak: 0,
});
}, []);
return (
<div className="results-screen">
<style>{STYLES}</style>
@ -397,20 +389,12 @@ export const Results = () => {
const navigate = useNavigate();
const results = useResults((s) => s.results);
const clearResults = useResults((s) => s.clearResults);
const { setUserMetrics, payload } = useExamConfigStore();
const { payload } = useExamConfigStore();
const isTargeted = payload?.mode === "TARGETED";
useEffect(() => {
if (results)
setUserMetrics({
xp: results.total_xp,
questions: results.correct_count,
streak: 0,
});
}, [results]);
function handleFinishExam() {
useExamConfigStore.getState().clearPayload();
clearResults();
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 { 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 { QUEST_ARCS } from "../data/questData";
@ -21,24 +21,37 @@ export interface QuestSummary {
activeNodes: number;
claimableNodes: number;
lockedNodes: number;
totalXP: number;
earnedXP: number;
// totalXP removed — node definitions no longer carry an XP value.
// Awarded XP only comes back from ClaimedRewardResponse at claim time.
earnedXP: number; // accumulated from claim responses, stored in state
arcsCompleted: number;
totalArcs: number;
earnedTitles: string[];
earnedTitles: string[]; // accumulated from claim responses, stored in state
crewRank: CrewRank;
}
// ─── Store — ONLY raw state + actions, never derived values ───────────────────
// Storing functions that return new objects/arrays in Zustand causes infinite
// re-render loops because Zustand uses Object.is to detect changes.
// All derived values live below as plain helper functions instead.
// ─── Store ────────────────────────────────────────────────────────────────────
interface QuestStore {
arcs: QuestArc[];
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;
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;
}
@ -47,22 +60,29 @@ export const useQuestStore = create<QuestStore>()(
(set) => ({
arcs: QUEST_ARCS,
activeArcId: QUEST_ARCS[0].id,
earnedXP: 0,
earnedTitles: [],
setActiveArc: (arcId) => set({ activeArcId: arcId }),
claimNode: (arcId, nodeId) =>
claimNode: (arcId, nodeId, xpAwarded = 0, titlesAwarded = []) =>
set((state) => ({
// Accumulate XP and titles from the claim response
earnedXP: state.earnedXP + xpAwarded,
earnedTitles: [...state.earnedTitles, ...titlesAwarded],
arcs: state.arcs.map((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;
return {
...arc,
nodes: arc.nodes.map((n, i) => {
if (n.id === nodeId)
return { ...n, status: "completed" as NodeStatus };
if (n.node_id === nodeId) return { ...n, status: "completed" };
// Unlock the next locked node in sequence
if (i === nodeIdx + 1 && n.status === "locked")
return { ...n, status: "active" as NodeStatus };
return { ...n, status: "active" };
return n;
}),
};
@ -77,34 +97,21 @@ export const useQuestStore = create<QuestStore>()(
partialize: (state) => ({
arcs: state.arcs,
activeArcId: state.activeArcId,
earnedXP: state.earnedXP,
earnedTitles: state.earnedTitles,
}),
},
),
);
// ─── Standalone helper functions ──────────────────────────────────────────────
// Call these in your components AFTER selecting arcs from the store.
// Because they take arcs as an argument (not selected from the store),
// they never cause re-render loops.
//
// Usage:
// const arcs = useQuestStore(s => s.arcs);
// const summary = getQuestSummary(arcs);
// const rank = getCrewRank(arcs);
export function getEarnedXP(arcs: QuestArc[]): number {
return arcs
.flatMap((a) => a.nodes)
.filter((n) => n.status === "completed")
.reduce((sum, n) => sum + n.reward.xp, 0);
}
export function getCrewRank(arcs: QuestArc[]): CrewRank {
const xp = getEarnedXP(arcs);
export function getCrewRank(earnedXP: number): CrewRank {
// Accepts earnedXP directly — no longer iterates nodes (reward.xp is gone)
const ladder = [...CREW_RANKS];
let idx = 0;
for (let i = ladder.length - 1; i >= 0; i--) {
if (xp >= ladder[i].xpRequired) {
if (earnedXP >= ladder[i].xpRequired) {
idx = i;
break;
}
@ -116,7 +123,7 @@ export function getCrewRank(arcs: QuestArc[]): CrewRank {
progressToNext: nextRank
? Math.min(
1,
(xp - current.xpRequired) /
(earnedXP - current.xpRequired) /
(nextRank.xpRequired - current.xpRequired),
)
: 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 earnedXP = getEarnedXP(arcs);
return {
totalNodes: allNodes.length,
completedNodes: allNodes.filter((n) => n.status === "completed").length,
activeNodes: allNodes.filter((n) => n.status === "active").length,
claimableNodes: allNodes.filter((n) => n.status === "claimable").length,
lockedNodes: allNodes.filter((n) => n.status === "locked").length,
totalXP: allNodes.reduce((s, n) => s + n.reward.xp, 0),
earnedXP,
arcsCompleted: arcs.filter((a) =>
a.nodes.every((n) => n.status === "completed"),
).length,
totalArcs: arcs.length,
earnedTitles: allNodes
.filter((n) => n.status === "completed" && n.reward.title)
.map((n) => n.reward.title!),
crewRank: getCrewRank(arcs),
earnedTitles,
crewRank: getCrewRank(earnedXP),
};
}
@ -153,11 +160,12 @@ export function getClaimableCount(arcs: QuestArc[]): number {
.length;
}
// node_id is the new primary key — replaces old n.id
export function getNode(
arcs: QuestArc[],
nodeId: string,
): 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 {

View File

@ -2,53 +2,59 @@
// Swap dummy data for API responses later — shape stays the same.
export type RequirementType =
| "questions"
| "accuracy"
| "streak"
| "sessions"
| "topics"
| "xp"
| "leaderboard";
| "QUESTIONS_ANSWERED"
| "SESSIONS_COMPLETED"
| "MIN_ACCURACY"
| "DAILY_STREAK"
| "LIFETIME_XP"
| "LEADERBOARD_RANK";
export type NodeStatus = "locked" | "active" | "claimable" | "completed";
export type NodeStatus = "LOCKED" | "ACTIVE" | "CLAIMABLE" | "COMPLETED";
export type RewardItem = "streak_shield" | "xp_boost" | "title";
export interface QuestReward {
xp: number;
title?: string; // crew rank title, e.g. "Navigator"
item?: RewardItem;
itemLabel?: string; // human-readable, e.g. "Streak Shield ×1"
}
export interface QuestNode {
id: string;
title: string;
flavourText: string;
islandName: string; // displayed under the node
emoji: string; // island character emoji
requirement: {
type: RequirementType;
target: number;
label: string; // e.g. "questions answered"
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;
};
progress: number; // 0 → requirement.target (API will fill this)
status: NodeStatus;
reward: QuestReward;
reward_items: InventoryItem[];
status: string;
current_value: 0;
}
export interface QuestArc {
id: string;
name: string; // "East Blue", "Alabasta", "Skypiea"
subtitle: string; // short flavour line
emoji: string;
accentColor: string; // CSS color for this arc's theme
accentDark: string;
bgFrom: string; // gradient start for arc header
bgTo: string;
name: string;
description: string;
image_url: string;
sequence_order: number;
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) ───────────────────────
export const CREW_RANKS = [
{ 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: "pirate_king", label: "Pirate King", emoji: "🏴‍☠️", xpRequired: 10000 },
] 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 { Lesson, LessonsResponse } from "../types/lesson";
import type {
ClaimedRewardResponse,
QuestArc,
UserInventory,
UserTitle,
} from "../types/quest";
import type {
SessionAnswerResponse,
SessionQuestionsResponse,
@ -242,5 +248,46 @@ class ApiClient {
async fetchPredictedScore(token: string): Promise<PredictedScore> {
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);