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