diff --git a/src/components/ChestOpenModal.tsx b/src/components/ChestOpenModal.tsx index a899c00..4456836 100644 --- a/src/components/ChestOpenModal.tsx +++ b/src/components/ChestOpenModal.tsx @@ -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("idle"); const [showXP, setShowXP] = useState(false); const timerRef = useRef | null>(null); @@ -464,40 +469,55 @@ export const ChestOpenModal = ({ node, onClose }: Props) => { [], ); - const rewards = [ - { - key: "xp", - cls: "xp-row", - icon: "⚡", - lbl: "XP Gained", - val: `+${node.reward.xp} XP`, - delay: "0.05s", - }, - ...(node.reward.title - ? [ - { - key: "title", - cls: "", - icon: "🏴‍☠️", - lbl: "Crew Title", - val: node.reward.title, - delay: "0.15s", - }, - ] - : []), - ...(node.reward.itemLabel - ? [ - { - key: "item", - cls: "", - icon: "🎁", - lbl: "Item", - val: node.reward.itemLabel, - delay: "0.25s", - }, - ] - : []), - ]; + // ── Build reward rows from ClaimedRewardResponse ────────────────────────── + // claimResult may be null while the API call is in flight; we show a loading + // state in that case rather than crashing or showing stale data. + const xpAwarded = claimResult?.xp_awarded ?? 0; + + // Defensively coerce to arrays — the API may return null, a single object, + // or omit these fields entirely rather than returning an empty array. + const titlesAwarded = Array.isArray(claimResult?.title_unlocked) + ? claimResult!.title_unlocked + : claimResult?.title_unlocked + ? [claimResult.title_unlocked] + : []; + const itemsAwarded = Array.isArray(claimResult?.items_awarded) + ? claimResult!.items_awarded + : claimResult?.items_awarded + ? [claimResult.items_awarded] + : []; + + const rewards = claimResult + ? [ + // XP row — always present + { + key: "xp", + cls: "xp-row", + icon: "⚡", + lbl: "XP Gained", + val: `+${xpAwarded} XP`, + delay: "0.05s", + }, + // One row per unlocked title (usually 0 or 1) + ...titlesAwarded.map((t, i) => ({ + key: `title-${t.id}`, + cls: "", + icon: "🏴‍☠️", + lbl: "Crew Title", + val: t.name, + delay: `${0.1 + i * 0.1}s`, + })), + // One row per awarded item + ...itemsAwarded.map((inv, i) => ({ + key: `item-${inv.id}`, + cls: "", + icon: "🎁", + lbl: inv.item.type ?? "Item", + val: inv.item.name, + delay: `${0.1 + (titlesAwarded.length + i) * 0.1}s`, + })), + ] + : []; const chestClass = phase === "idle" @@ -534,7 +554,7 @@ export const ChestOpenModal = ({ node, onClose }: Props) => { /> ))} - {/* Crepuscular rays (appear on open) */} + {/* Crepuscular rays */} {(phase === "opening" || phase === "revealed") && (
{RAYS.map((r) => ( @@ -639,8 +659,12 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {
))} - {/* XP blast */} - {showXP &&
+{node.reward.xp} XP
} + {/* XP blast — uses xp_awarded from claimResult */} + {showXP && ( +
+ {xpAwarded > 0 ? `+${xpAwarded} XP` : "✨"} +
+ )} {/* Card */}
e.stopPropagation()}> @@ -671,16 +695,24 @@ export const ChestOpenModal = ({ node, onClose }: Props) => {

YOUR HARD WORK HAS PAID OFF, PIRATE

)} + {phase === "shaking" && ( <>

The chest stirs...

⚡ ⚡ ⚡

)} + {phase === "revealed" && ( <>

⚓ Spoils of Victory

+ {/* claimResult not yet available — API still in flight */} + {!claimResult && ( +

+ ⚡ Counting your spoils... +

+ )} {rewards.map((r) => (
{
- {/* Skip link for impatient pirates */} + {/* Skip link */} {phase === "revealed" && (

tap anywhere to continue diff --git a/src/components/InfoHeader.tsx b/src/components/InfoHeader.tsx index 181b9af..6f9e734 100644 --- a/src/components/InfoHeader.tsx +++ b/src/components/InfoHeader.tsx @@ -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 = { + questions: "❓", + accuracy: "🎯", + streak: "🔥", + sessions: "📚", + topics: "🗺️", + xp: "⚡", + leaderboard: "🏆", +}; + +const REQ_LABEL: Record = { + 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 (

- {/* Header */}
⚓ Crew Rank {earnedXP.toLocaleString()} XP
- {/* Current rank label */}
- {/* Scrollable rank track */}
- {/* Baseline dim line */}
- - {/* Gold progress line */}
- - {/* Ship marker */}
- - {/* Rank nodes */} {ladder.map((r, i) => { const state = i < currentIdx @@ -556,19 +535,12 @@ const RankLadder = ({ })}
- - {/* Footer */} - {/*
- - View quest map -
*/}
); }; // ─── 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( + 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 ( <> {claimingNode && ( - + )} ); @@ -691,10 +685,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {

{roleLabel}

+ @@ -702,6 +697,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
+
)} @@ -753,35 +749,41 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {

⚓ All caught up — keep sailing!

) : ( 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 (
!isClaimable && handleViewAll()} >
- {isClaimable ? "📦" : node.emoji} + {isClaimable ? "📦" : nodeEmoji}
-

{node.title}

+ {/* node.name replaces old node.title */} +

{node.name ?? "—"}

{isClaimable ? (

✨ Ready to claim!

) : (

- {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}%

)}
@@ -804,8 +806,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => { )}
- - View quest map + View quest map
@@ -813,7 +814,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => { {claimingNode && ( - + )} ); diff --git a/src/components/InventoryButton.tsx b/src/components/InventoryButton.tsx new file mode 100644 index 0000000..46796ca --- /dev/null +++ b/src/components/InventoryButton.tsx @@ -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 = { + 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: + * + * + */ +export const InventoryButton = ({}: {}) => { + const [open, setOpen] = useState(false); + const activeEffects = useInventoryStore((s) => s.activeEffects); + const liveEffects = getLiveEffects(activeEffects); + const hasActive = liveEffects.length > 0; + + return ( + <> + + + + + {open && setOpen(false)} />} + + ); +}; + +// ─── ActiveEffectBanner ─────────────────────────────────────────────────────── +/** + * Shows pills for each currently-active effect. + * Place wherever you want a contextual reminder (pretest screen, dashboard, etc.) + * + * Usage: + * + * ← 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 ( + <> + +
+ {live.map((e, i) => ( +
+ + {itemIcon(e.item.effect_type)} + + + {e.item.name} + {e.item.effect_type === "xp_boost" && e.item.effect_value + ? ` ×${e.item.effect_value}` + : ""} + + + {formatTimeLeft(e.expires_at)} + +
+ ))} +
+ + ); +}; diff --git a/src/components/InventoryModal.tsx b/src/components/InventoryModal.tsx new file mode 100644 index 0000000..3b371ae --- /dev/null +++ b/src/components/InventoryModal.tsx @@ -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 = { + 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 ( +
+
+ + {/* Icon */} +
+ {itemIcon(inv.item.effect_type)} + {isActive &&
} +
+ + {/* Name + description */} +

{inv.item.name}

+

{inv.item.description}

+ + {/* Qty + type */} +
+ ×{inv.quantity} + + {inv.item.type.replace(/_/g, " ")} + +
+ + {/* Time remaining if active */} + {isActive && activeEffect && ( +
+ {formatTimeLeft(activeEffect.expires_at)} remaining +
+ )} + + {/* Activate button */} + +
+ ); +}; + +// ─── 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 | 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 ( + <> + + +
+
e.stopPropagation()}> + {/* Handle */} +
+
+
+ + {/* Header */} +
+
+ ⚓ Pirate's Hold +

Inventory

+
+ +
+ + {/* Active effects bar */} + {liveEffects.length > 0 && ( +
+ {liveEffects.map((e) => ( +
+ + {itemIcon(e.item.effect_type)} + + {e.item.name} + + {formatTimeLeft(e.expires_at)} + +
+ ))} +
+ )} + +
+

+ {items.length > 0 + ? `${items.length} item${items.length !== 1 ? "s" : ""} in your hold` + : "Your hold"} +

+ + {/* Scroll area */} +
+ {loading && items.length === 0 ? ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ) : items.length === 0 ? ( +
+ 🏴‍☠️ +

Your hold is empty — claim quests to earn items!

+
+ ) : ( +
+ {items.map((inv, i) => ( + + ))} +
+ )} + + {/* Error inline */} + {error && ( +

+ ⚠️ {error} +

+ )} +
+
+
+ + {/* Success toast */} + {showToast &&
{toastMsg}
} + + ); +}; diff --git a/src/components/QuestNodeModal.tsx b/src/components/QuestNodeModal.tsx index 7a56a5e..85001a0 100644 --- a/src/components/QuestNodeModal.tsx +++ b/src/components/QuestNodeModal.tsx @@ -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 = { + questions: "questions answered", + accuracy: "% accuracy", + streak: "day streak", + sessions: "sessions", + topics: "topics covered", + xp: "XP earned", + leaderboard: "leaderboard rank", +}; + +const reqIcon = (type: string): string => + ({ + questions: "❓", + accuracy: "🎯", + streak: "🔥", + sessions: "📚", + topics: "🗺️", + xp: "⚡", + leaderboard: "🏆", + })[type] ?? "⭐"; // ─── Styles ─────────────────────────────────────────────────────────────────── const STYLES = ` @@ -30,11 +54,9 @@ const STYLES = ` } @keyframes qnmUp { from{transform:translateY(100%);opacity:0} to{transform:translateY(0);opacity:1} } - /* Handle */ .qnm-handle-row { display:flex; justify-content:center; padding:0.8rem 0 0.3rem; flex-shrink:0; } .qnm-handle { width:38px; height:4px; border-radius:100px; background:rgba(255,255,255,0.12); } - /* Close btn */ .qnm-close { position:absolute; top:0.9rem; right:1.1rem; z-index:10; width:30px; height:30px; border-radius:50%; @@ -50,8 +72,6 @@ const STYLES = ` height: 200px; overflow: hidden; background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-bot) 55%, var(--sea-col) 100%); } - - /* Sea waves */ .qnm-sea { position:absolute; bottom:0; left:0; right:0; height:52px; background: var(--sea-col); overflow:hidden; @@ -69,25 +89,19 @@ const STYLES = ` 50% { transform: translateX(15%) scaleY(1.08);} 100%{ transform: translateX(0) scaleY(1); } } - - /* Floating clouds */ .qnm-cloud { position:absolute; border-radius:50px; - background: rgba(255,255,255,0.18); - filter: blur(4px); + background: rgba(255,255,255,0.18); filter: blur(4px); animation: qnmDrift var(--cdur,18s) linear infinite; } @keyframes qnmDrift { - 0% { transform: translateX(-120px); opacity:0; } + 0% { transform: translateX(-120px); opacity:0; } 10% { opacity:1; } 90% { opacity:1; } 100%{ transform: translateX(calc(100vw + 120px)); opacity:0; } } - - /* ── The 3D island container ── */ .qnm-island-3d-wrap { - position: absolute; - left: 50%; bottom: 40px; + position: absolute; left: 50%; bottom: 40px; transform: translateX(-50%); perspective: 420px; width: 220px; height: 140px; @@ -102,16 +116,12 @@ const STYLES = ` 0% { transform: rotateX(22deg) rotateY(0deg); } 100% { transform: rotateX(22deg) rotateY(360deg); } } - - /* Island layers — stacked in 3D */ - .qnm-il { /* island layer base class */ + .qnm-il { position: absolute; left: 50%; bottom: 0; transform-origin: bottom center; border-radius: 50%; transform-style: preserve-3d; } - - /* Water base disc */ .qnm-il-water { width: 200px; height: 44px; margin-left: -100px; background: radial-gradient(ellipse 80% 100% at 50% 40%, var(--sea-hi), var(--sea-col)); @@ -120,12 +130,7 @@ const STYLES = ` box-shadow: 0 0 40px var(--sea-col); animation: qnmWaterShimmer 3s ease-in-out infinite; } - @keyframes qnmWaterShimmer { - 0%,100%{ opacity:1; } - 50% { opacity:0.82; } - } - - /* Ripple rings on water */ + @keyframes qnmWaterShimmer { 0%,100%{ opacity:1; } 50%{ opacity:0.82; } } .qnm-ripple { position:absolute; left:50%; top:50%; border-radius:50%; border:1.5px solid rgba(255,255,255,0.25); @@ -136,8 +141,6 @@ const STYLES = ` 0% { width:60px; height:20px; margin-left:-30px; margin-top:-10px; opacity:0.7; } 100%{ width:180px; height:60px; margin-left:-90px; margin-top:-30px; opacity:0; } } - - /* Island ground */ .qnm-il-ground { width: 160px; height: 36px; margin-left: -80px; background: radial-gradient(ellipse at 40% 30%, var(--terr-hi), var(--terr-mid) 55%, var(--terr-lo)); @@ -145,8 +148,6 @@ const STYLES = ` transform: translateZ(14px); box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 -4px 8px rgba(0,0,0,0.25); } - - /* Island side face — gives the 3D depth illusion */ .qnm-il-side { width: 158px; height: 22px; margin-left: -79px; bottom: -12px; @@ -154,8 +155,6 @@ const STYLES = ` clip-path: ellipse(79px 100% at 50% 0%); transform: translateZ(8px) rotateX(-8deg); } - - /* Peak */ .qnm-il-peak { width: 80px; height: 60px; margin-left: -40px; bottom: 26px; @@ -169,28 +168,18 @@ const STYLES = ` 0%,100%{ transform: translateZ(26px) translateY(0); } 50% { transform: translateZ(26px) translateY(-4px); } } - - /* Floating decoration layer (trees, cactus, cloud orb, etc.) */ .qnm-il-deco { position: absolute; bottom: 56px; left: 50%; transform: translateZ(42px); animation: qnmDecoFloat 3s ease-in-out infinite; } @keyframes qnmDecoFloat { - 0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); } - 50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); } + 0%,100%{ transform: translateZ(42px) translateY(0) rotate(0deg); } + 50% { transform: translateZ(42px) translateY(-7px) rotate(3deg); } } .qnm-deco-emoji { font-size:1.4rem; filter:drop-shadow(0 4px 8px rgba(0,0,0,0.5)); } - - /* Flag pole on active */ - .qnm-il-flag { - position:absolute; bottom:56px; left:50%; - transform: translateZ(50px) translateX(12px); - } - .qnm-flag-pole { - width:2px; height:26px; background:#7c4a1e; - border-radius:2px; - } + .qnm-il-flag { position:absolute; bottom:56px; left:50%; transform: translateZ(50px) translateX(12px); } + .qnm-flag-pole { width:2px; height:26px; background:#7c4a1e; border-radius:2px; } .qnm-flag-cloth { position:absolute; top:2px; left:2px; width:16px; height:11px; @@ -198,19 +187,14 @@ const STYLES = ` animation: qnmFlagWave 1.2s ease-in-out infinite; transform-origin:left center; } - @keyframes qnmFlagWave { - 0%,100%{ transform:skewY(0deg); } - 50% { transform:skewY(-10deg); } - } - - /* Stars / sparkles above completed island */ + @keyframes qnmFlagWave { 0%,100%{ transform:skewY(0deg); } 50%{ transform:skewY(-10deg); } } .qnm-star { position:absolute; font-size:1rem; animation: qnmStarPop var(--sdur,2s) ease-in-out infinite; animation-delay: var(--sdel,0s); } @keyframes qnmStarPop { - 0%,100%{ transform:scale(1) translateY(0); opacity:0.8; } + 0%,100%{ transform:scale(1) translateY(0); opacity:0.8; } 50% { transform:scale(1.4) translateY(-8px); opacity:1; } } @@ -221,8 +205,6 @@ const STYLES = ` padding:1.1rem 1.25rem 0.5rem; } .qnm-body::-webkit-scrollbar { display:none; } - - /* Title block */ .qnm-title-block { position:relative; } .qnm-arc-tag { display:inline-flex; align-items:center; gap:0.3rem; @@ -240,8 +222,6 @@ const STYLES = ` font-family:'Nunito Sans',sans-serif; font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.38); } - - /* Flavour quote */ .qnm-flavour { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07); border-left:3px solid var(--ac); @@ -253,8 +233,6 @@ const STYLES = ` font-size:0.82rem; color:rgba(255,255,255,0.55); font-style:italic; line-height:1.6; } - - /* Objective card */ .qnm-obj-card { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); @@ -271,9 +249,7 @@ const STYLES = ` font-family:'Nunito',sans-serif; font-size:0.78rem; font-weight:900; color:var(--ac); } - .qnm-obj-row { - display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem; - } + .qnm-obj-row { display:flex; align-items:center; gap:0.65rem; margin-bottom:0.7rem; } .qnm-obj-icon { width:38px; height:38px; border-radius:12px; flex-shrink:0; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.08); @@ -287,8 +263,6 @@ const STYLES = ` font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.35); margin-top:0.05rem; } - - /* Progress bar */ .qnm-bar-track { height:9px; background:rgba(255,255,255,0.07); border-radius:100px; overflow:hidden; margin-bottom:0.3rem; @@ -305,21 +279,16 @@ const STYLES = ` font-size:0.65rem; font-weight:800; color:rgba(255,255,255,0.28); } .qnm-bar-nums span:first-child { color:var(--ac); } - - /* ── HOW TO COMPLETE section ── */ .qnm-howto-label { font-size:0.58rem; font-weight:800; letter-spacing:0.14em; text-transform:uppercase; color:rgba(255,255,255,0.3); margin-bottom:0.55rem; margin-top:0.3rem; } - .qnm-howto-badges { - display:flex; flex-wrap:wrap; gap:0.4rem; - } + .qnm-howto-badges { display:flex; flex-wrap:wrap; gap:0.4rem; } .qnm-howto-badge { display:flex; align-items:center; gap:0.3rem; padding:0.38rem 0.75rem; - background:rgba(255,255,255,0.06); - border:1px solid rgba(255,255,255,0.1); + background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1); border-radius:100px; font-family:'Nunito',sans-serif; font-size:0.72rem; font-weight:800; color:rgba(255,255,255,0.7); @@ -332,19 +301,14 @@ const STYLES = ` to { opacity:1; transform:scale(1) translateY(0); } } .qnm-howto-badge:hover { - background:rgba(255,255,255,0.1); - border-color:rgba(255,255,255,0.2); - color:white; - transform:translateY(-1px); + background:rgba(255,255,255,0.1); border-color:rgba(255,255,255,0.2); + color:white; transform:translateY(-1px); } - /* Highlight badge = accent coloured */ .qnm-howto-badge.hi { background:color-mix(in srgb, var(--ac) 18%, transparent); border-color:color-mix(in srgb, var(--ac) 45%, transparent); color:var(--ac); } - - /* Locked banner */ .qnm-locked-banner { display:flex; align-items:center; gap:0.7rem; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.07); @@ -362,11 +326,8 @@ const STYLES = ` font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:600; color:rgba(255,255,255,0.22); margin-top:0.1rem; } - - /* Reward card */ .qnm-reward-card { - background:rgba(251,191,36,0.07); - border:1px solid rgba(251,191,36,0.22); + background:rgba(251,191,36,0.07); border:1px solid rgba(251,191,36,0.22); border-radius:18px; padding:0.9rem 1rem; } .qnm-reward-label { @@ -409,71 +370,12 @@ const STYLES = ` } `; -// ─── Per-arc terrain themes ─────────────────────────────────────────────────── -interface Terrain { - skyTop: string; - skyBot: string; - seaCol: string; - seaHi: string; - terrHi: string; - terrMid: string; - terrLo: string; - peakHi: string; - peakMid: string; - peakLo: string; - decos: string[]; -} - -const TERRAIN: Record = { - 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 = { questions: { title: "How to complete this", @@ -540,12 +442,7 @@ const HOW_TO: Record = { }, }; -// ─── 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 = { + 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 = ({ />
- {/* Ripple rings on water surface */} + {/* Ripple rings */}
- {/* Water base */}
- - {/* Island side face */}
- - {/* Island ground — shaped to match QuestMap */}
- {/* Peak / hill — shaped to match QuestMap */} {!isLocked && (
))} - {/* Pirate flag on active */} {isActive && (
)} - - {/* Chest bouncing on claimable */} {isClaimable && (
)} - - {/* Lock icon on locked */} {isLocked && (
{/* Sparkles for completed */} - {isCompleted && ( - <> - {[ - { left: "30%", top: "18%", sdur: "2s", sdel: "0s" }, - { left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" }, - { left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" }, - { left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" }, - ].map((s, i) => ( - - ✨ - - ))} - - )} + {isCompleted && + [ + { left: "30%", top: "18%", sdur: "2s", sdel: "0s" }, + { left: "62%", top: "12%", sdur: "2.4s", sdel: "0.6s" }, + { left: "20%", top: "38%", sdur: "1.8s", sdel: "1.1s" }, + { left: "74%", top: "32%", sdur: "2.2s", sdel: "0.3s" }, + ].map((s, i) => ( + + ✨ + + ))} {/* 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 (
- {/* 3D island stage */} - + {/* 3D island stage — now receives full arc for theme generation */} + {/* Scrollable content */}
- {/* Title */} + {/* Title block */}
-
- {reqIcon(node.requirement.type)} Quest -
-

{node.title}

-

📍 {node.islandName}

+ {/* req_type replaces node.requirement.type */} +
{reqIcon(node.req_type)} Quest
+ {/* node.name replaces node.title */} +

{node.name ?? "—"}

+ {/* node.islandName removed — reuse node.name as location label */} +

📍 {node.name ?? "—"}

- {/* Flavour */} -
-

{node.flavourText}

-
+ {/* Flavour — node.description replaces node.flavourText */} + {node.description && ( +
+

{node.description}

+
+ )} {/* Objective */}
@@ -964,19 +925,18 @@ export const QuestNodeModal = ({ )}
-
- {reqIcon(node.requirement.type)} -
+
{reqIcon(node.req_type)}
+ {/* req_target + derived label replace node.requirement.target/label */}

- {node.requirement.target} {node.requirement.label} + {node.req_target} {reqLabel}

{isCompleted ? "✅ Completed — treasure claimed!" : isLocked ? "🔒 Complete previous quests first" - : `${node.progress} / ${node.requirement.target} done`} + : `${node.current_value} / ${node.req_target} done`}

@@ -990,14 +950,15 @@ export const QuestNodeModal = ({ style={{ width: mounted ? `${progress}%` : "0%" }} />
+ {/* current_value / req_target replace old progress / requirement.target */}
- {node.progress} - {node.requirement.target} + {node.current_value} + {node.req_target}
)} - {/* How-to badges — show when active or claimable */} + {/* How-to badges */} {(isActive || isClaimable) && howTo && ( <>

@@ -1036,19 +997,26 @@ export const QuestNodeModal = ({

)} - {/* Reward */} + {/* Reward — sources from flat node reward fields */}

📦 Treasure Chest

-
⚡ +{node.reward.xp} XP
- {node.reward.title && ( -
🏴‍☠️ {node.reward.title}
+ {/* reward_coins replaces node.reward.xp */} + {node.reward_coins > 0 && ( +
🪙 +{node.reward_coins}
)} - {node.reward.itemLabel && ( + {/* reward_title is now a nested object, not a string */} + {node.reward_title?.name && (
- 🎁 {node.reward.itemLabel} + 🏴‍☠️ {node.reward_title.name}
)} + {/* reward_items is now an array — show one pill per item */} + {node.reward_items?.map((inv) => ( +
+ 🎁 {inv.item.name} +
+ ))}
@@ -1064,9 +1032,9 @@ export const QuestNodeModal = ({ ) : isLocked ? (

🔒 Locked — keep sailing

) : ( + /* remaining replaces node.requirement.target - node.progress */

- {progress}% complete · {node.requirement.target - node.progress}{" "} - {node.requirement.label} remaining + {progress}% complete · {remaining} {reqLabel} remaining

)}
diff --git a/src/pages/student/Home.tsx b/src/pages/student/Home.tsx index 29b03ec..ea81f25 100644 --- a/src/pages/student/Home.tsx +++ b/src/pages/student/Home.tsx @@ -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 = [ diff --git a/src/pages/student/QuestMap.tsx b/src/pages/student/QuestMap.tsx index 3ef97bb..ba52839 100644 --- a/src/pages/student/QuestMap.tsx +++ b/src/pages/student/QuestMap.tsx @@ -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 = { - 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 ``, - // 1: tall mountain peak ``, - // 2: wide flat shoal ``, - // 3: jagged rocky reef ``, - // 4: crescent (right side bites in) ``, - // 5: teardrop/pear ``, ]; @@ -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 = - { - 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 = { - 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(); +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 = { questions: "❓", accuracy: "🎯", @@ -306,6 +392,30 @@ const REQ_ICON: Record = { xp: "⚡", leaderboard: "🏆", }; + +// req_type → human-readable label (replaces the old requirement.label field) +const REQ_LABEL: Record = { + 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 = { + 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 ( )} - {/* Quest emoji */} + {/* Node emoji — derived from req_type */} {!isLocked && ( - {node.emoji} + {nodeEmoji} )} @@ -575,7 +704,7 @@ const IslandNode = ({ )} - {/* Island name label */} + {/* Island name label — uses node.name (truncated) */} - {node.islandName?.toUpperCase()} + {truncate(node.name).toUpperCase()} {/* 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)} >
-

{node.title}

+ {/* node.name replaces old node.title */} +

{node.name ?? "—"}

+ {/* Live XP from auth store */}
- +{node.reward.xp} + {userXp} XP
+ {(isActive || isClaimable) && ( <>
+ {/* req_type + current_value / req_target + derived 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}

)} + {isLocked && (

- 🔒 {node.requirement.target} {node.requirement.label} to unlock + 🔒 {node.req_target} {REQ_LABEL[node.req_type] ?? node.req_type}{" "} + to unlock

)} + {isCompleted && (

✅ Conquered!

)} + {isClaimable && ( +
+
+ ); + } + + 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 (
@@ -785,48 +1091,29 @@ export const QuestMap = () => { {/* Header */}
- {/*

🏴‍☠️ Treasure Quests

-

Chart your course across the Grand Line

*/} - {/*
- {[ - { - 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) => ( -
- {s.e} - {s.v} - {s.l} -
- ))} -
*/}
- {arcs.map((a) => ( - - ))} + {[...arcs] + .sort((a, b) => a.sequence_order - b.sequence_order) + .map((a) => { + const t = getArcTheme(a); + return ( + + ); + })}
@@ -855,12 +1142,12 @@ export const QuestMap = () => {
-
{arc.emoji}
+
{theme!.emoji}

{arc.name}

-

{arc.subtitle}

+

{arc.description}

{
- {/* ── Single SVG canvas for the whole map ── */} + {/* SVG map canvas */} - {/* 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 ( { 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) => ( { /> ))} - {/* Arc complete seal */} - {done === nodes.length && ( + {done === sorted.length && ( { {selectedNode && ( n.id === selectedNode.id)} + nodeIndex={selectedNode.sequence_order} onClose={() => setSelectedNode(null)} onClaim={() => { setSelectedNode(null); @@ -990,8 +1277,38 @@ export const QuestMap = () => { }} /> )} + {claimingNode && ( - + + )} + + {/* Claim error toast — shown if API call failed but modal is already open */} + {claimError && ( +
+ ⚠️ {claimError} — your progress is saved +
)}
); diff --git a/src/pages/student/practice/Results.tsx b/src/pages/student/practice/Results.tsx index 079b36c..dcc4b8a 100644 --- a/src/pages/student/practice/Results.tsx +++ b/src/pages/student/practice/Results.tsx @@ -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 (
@@ -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"); } diff --git a/src/stores/useInventoryStore.ts b/src/stores/useInventoryStore.ts new file mode 100644 index 0000000..f96068c --- /dev/null +++ b/src/stores/useInventoryStore.ts @@ -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()( + 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`; +} diff --git a/src/stores/useQuestStore.ts b/src/stores/useQuestStore.ts index 0f07289..8722543 100644 --- a/src/stores/useQuestStore.ts +++ b/src/stores/useQuestStore.ts @@ -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()( (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()( 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 { diff --git a/src/types/quest.ts b/src/types/quest.ts index e18b166..9144ffc 100644 --- a/src/types/quest.ts +++ b/src/types/quest.ts @@ -2,53 +2,59 @@ // Swap dummy data for API responses later — shape stays the same. export type RequirementType = - | "questions" - | "accuracy" - | "streak" - | "sessions" - | "topics" - | "xp" - | "leaderboard"; + | "QUESTIONS_ANSWERED" + | "SESSIONS_COMPLETED" + | "MIN_ACCURACY" + | "DAILY_STREAK" + | "LIFETIME_XP" + | "LEADERBOARD_RANK"; -export type NodeStatus = "locked" | "active" | "claimable" | "completed"; +export type NodeStatus = "LOCKED" | "ACTIVE" | "CLAIMABLE" | "COMPLETED"; export type RewardItem = "streak_shield" | "xp_boost" | "title"; -export interface QuestReward { - xp: number; - title?: string; // crew rank title, e.g. "Navigator" - item?: RewardItem; - itemLabel?: string; // human-readable, e.g. "Streak Shield ×1" -} - export interface QuestNode { - id: string; - title: string; - flavourText: string; - islandName: string; // displayed under the node - emoji: string; // island character emoji - requirement: { - type: RequirementType; - target: number; - label: string; // e.g. "questions answered" + node_id: string; + name: string; + description: string; + sequence_order: number; + req_type: RequirementType; + req_target: number; + reward_coins: number; + reward_xp: number; + reward_title: { + name: string; + description: string; + id: string; }; - progress: number; // 0 → requirement.target (API will fill this) - status: NodeStatus; - reward: QuestReward; + reward_items: InventoryItem[]; + status: string; + current_value: 0; } export interface QuestArc { id: string; - name: string; // "East Blue", "Alabasta", "Skypiea" - subtitle: string; // short flavour line - emoji: string; - accentColor: string; // CSS color for this arc's theme - accentDark: string; - bgFrom: string; // gradient start for arc header - bgTo: string; + name: string; + description: string; + image_url: string; + sequence_order: number; nodes: QuestNode[]; } +export type Title = { + name: string; + description: string; + id: string; +}; + +export interface ClaimedRewardResponse { + message: string; + xp_awarded: 0; + coins_awarded: 0; + title_unlocked: Title[]; + items_awarded: InventoryItem[]; +} + // ─── Crew Rank ladder (shown on profile / leaderboard) ─────────────────────── export const CREW_RANKS = [ { id: "cabin_boy", label: "Cabin Boy", emoji: "⚓", xpRequired: 0 }, @@ -58,3 +64,44 @@ export const CREW_RANKS = [ { id: "emperor", label: "Emperor", emoji: "👑", xpRequired: 6000 }, { id: "pirate_king", label: "Pirate King", emoji: "🏴‍☠️", xpRequired: 10000 }, ] as const; + +export type UserTitle = { + title_id: string; + title: { + name: string; + description: string; + id: string; + }; + unlocked_at: string; + is_active: false; +}; + +export type InventoryItem = { + id: string; + item: { + name: string; + description: string; + type: string; + effect_type: string; + effect_value: number; + id: string; + }; + quantity: number; +}; +export type ActiveEffect = { + id: string; + item: { + name: string; + description: string; + type: string; + effect_type: string; + effect_value: number; + id: string; + }; + expires_at: string; +}; + +export interface UserInventory { + items: InventoryItem[]; + active_effects: ActiveEffect[]; +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 3ad33bc..229989d 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -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 { return this.authenticatedRequest(`/prediction/`, token); } + + /*------------QUEST JOURNEY-------------- */ + async fetchUserJourney(token: string): Promise { + return this.authenticatedRequest(`/journey/`, token); + } + async claimReward( + token: string, + node_id: string, + ): Promise { + return this.authenticatedRequest( + `/journey/claim/${node_id}`, + token, + { + method: "POST", + }, + ); + } + + /*------------INVENTORY-------------- */ + async fetchUserInventory(token: string): Promise { + return this.authenticatedRequest(`/inventory/`, token); + } + async activateItem(token: string, itemId: string): Promise { + return this.authenticatedRequest( + `/inventory/use/${itemId}`, + token, + ); + } + /*------------TITLES-------------- */ + async fetchUserTitles(token: string): Promise { + return this.authenticatedRequest(`/inventory/titles/`, token); + } + async equipTitle( + token: string, + titleData: { title_id: string }, + ): Promise { + return this.authenticatedRequest(`/inventory/titles/equip`, token, { + method: "POST", + body: JSON.stringify(titleData), + }); + } } export const api = new ApiClient(API_URL);