fix(api): fix api integration for quest map and adjacent components
This commit is contained in:
@ -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 = [
|
||||
// ── 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: `+${node.reward.xp} XP`,
|
||||
val: `+${xpAwarded} XP`,
|
||||
delay: "0.05s",
|
||||
},
|
||||
...(node.reward.title
|
||||
? [
|
||||
{
|
||||
key: "title",
|
||||
// One row per unlocked title (usually 0 or 1)
|
||||
...titlesAwarded.map((t, i) => ({
|
||||
key: `title-${t.id}`,
|
||||
cls: "",
|
||||
icon: "🏴☠️",
|
||||
lbl: "Crew Title",
|
||||
val: node.reward.title,
|
||||
delay: "0.15s",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(node.reward.itemLabel
|
||||
? [
|
||||
{
|
||||
key: "item",
|
||||
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: "Item",
|
||||
val: node.reward.itemLabel,
|
||||
delay: "0.25s",
|
||||
},
|
||||
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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
217
src/components/InventoryButton.tsx
Normal file
217
src/components/InventoryButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
675
src/components/InventoryModal.tsx
Normal file
675
src/components/InventoryModal.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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,12 +89,9 @@ 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 {
|
||||
@ -83,11 +100,8 @@ const STYLES = `
|
||||
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,8 +168,6 @@ 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);
|
||||
@ -181,16 +178,8 @@ const STYLES = `
|
||||
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,12 +187,7 @@ 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;
|
||||
@ -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,9 +788,8 @@ const IslandStage = ({
|
||||
</div>
|
||||
|
||||
{/* Sparkles for completed */}
|
||||
{isCompleted && (
|
||||
<>
|
||||
{[
|
||||
{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" },
|
||||
@ -861,8 +810,6 @@ const IslandStage = ({
|
||||
✨
|
||||
</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 */}
|
||||
{/* Flavour — node.description replaces node.flavourText */}
|
||||
{node.description && (
|
||||
<div className="qnm-flavour">
|
||||
<p className="qnm-flavour-text">{node.flavourText}</p>
|
||||
<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>
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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]}
|
||||
{node.progress}/{node.requirement.target}{" "}
|
||||
{node.requirement.label}
|
||||
{REQ_ICON[node.req_type]}
|
||||
{node.current_value}/{node.req_target}
|
||||
{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);
|
||||
|
||||
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];
|
||||
const done = completedCount(arc);
|
||||
const pct = Math.round((done / arc.nodes.length) * 100);
|
||||
// ── Fetch journey on mount ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let cancelled = false;
|
||||
|
||||
const handleClaim = (node: QuestNode) => setClaimingNode(node);
|
||||
const handleChestClose = () => {
|
||||
if (!claimingNode) return;
|
||||
claimNode(arc.id, claimingNode.id); // store handles state update + next unlock
|
||||
setClaimingNode(null);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const nodes = arc.nodes;
|
||||
const centres = nodes.map((_, i) => ({
|
||||
fetchJourney();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, syncFromAPI]);
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];
|
||||
const theme = arc ? getArcTheme(arc) : null;
|
||||
const done = arc ? completedCount(arc) : 0;
|
||||
const pct = arc ? Math.round((done / arc.nodes.length) * 100) : 0;
|
||||
|
||||
// ── Claim flow ────────────────────────────────────────────────────────────
|
||||
// Step 1: user taps "Open Chest" — open the modal immediately (animation
|
||||
// starts) and fire the API call in parallel so the result is usually ready
|
||||
// by the time the chest animation finishes (~2.5s).
|
||||
const handleClaim = useCallback(
|
||||
async (node: QuestNode) => {
|
||||
if (!token) return;
|
||||
setClaimingNode(node);
|
||||
setClaimResult(null);
|
||||
setClaimError(null);
|
||||
setClaimLoading(true);
|
||||
|
||||
try {
|
||||
const result = await api.claimReward(token, node.node_id);
|
||||
setClaimResult(result);
|
||||
} catch (err) {
|
||||
setClaimError(err instanceof Error ? err.message : "Claim failed");
|
||||
} finally {
|
||||
setClaimLoading(false);
|
||||
}
|
||||
},
|
||||
[token],
|
||||
);
|
||||
|
||||
// Step 2: user taps "Set Sail" in ChestOpenModal — commit to store & close.
|
||||
const handleChestClose = useCallback(() => {
|
||||
if (!claimingNode) return;
|
||||
const titlesUnlocked = Array.isArray(claimResult?.title_unlocked)
|
||||
? claimResult!.title_unlocked
|
||||
: claimResult?.title_unlocked
|
||||
? [claimResult.title_unlocked]
|
||||
: [];
|
||||
claimNode(
|
||||
arc.id,
|
||||
claimingNode.node_id,
|
||||
claimResult?.xp_awarded ?? 0,
|
||||
titlesUnlocked.map((t) => t.name),
|
||||
);
|
||||
setClaimingNode(null);
|
||||
setClaimResult(null);
|
||||
setClaimError(null);
|
||||
}, [claimingNode, claimResult, arc, claimNode]);
|
||||
|
||||
// ── Loading screen ────────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="qm-screen"
|
||||
style={{ alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<style>{STYLES}</style>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontFamily: "'Nunito',sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "2.5rem",
|
||||
marginBottom: "1rem",
|
||||
animation: "qmFabFloat 2s ease-in-out infinite",
|
||||
}}
|
||||
>
|
||||
⛵
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
CHARTING YOUR COURSE...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error screen ──────────────────────────────────────────────────────────
|
||||
if (fetchError || !arc) {
|
||||
return (
|
||||
<div
|
||||
className="qm-screen"
|
||||
style={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
<style>{STYLES}</style>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontFamily: "'Nunito',sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "2.5rem", marginBottom: "1rem" }}>🌊</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 800,
|
||||
color: "#ef4444",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{fetchError ?? "No quest data found"}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
marginTop: "1rem",
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: "100px",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
background: "transparent",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
cursor: "pointer",
|
||||
fontFamily: "'Nunito',sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...arc.nodes].sort(
|
||||
(a, b) => a.sequence_order - b.sequence_order,
|
||||
);
|
||||
const centres = sorted.map((_, i) => ({
|
||||
x: islandCX(i, arc.id),
|
||||
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) => (
|
||||
{[...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": a.accentColor } as React.CSSProperties}
|
||||
style={{ "--arc-accent": t.accent } 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") && (
|
||||
{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>
|
||||
);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
120
src/stores/useInventoryStore.ts
Normal file
120
src/stores/useInventoryStore.ts
Normal 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`;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
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;
|
||||
title: string;
|
||||
flavourText: string;
|
||||
islandName: string; // displayed under the node
|
||||
emoji: string; // island character emoji
|
||||
requirement: {
|
||||
type: RequirementType;
|
||||
target: number;
|
||||
label: string; // e.g. "questions answered"
|
||||
};
|
||||
progress: number; // 0 → requirement.target (API will fill this)
|
||||
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[];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user