|
|
|
@ -17,11 +17,10 @@ import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
|
|
|
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
|
|
|
|
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
|
|
|
|
import { PredictedScoreCard } from "./PredictedScoreCard";
|
|
|
|
import { PredictedScoreCard } from "./PredictedScoreCard";
|
|
|
|
import { ChestOpenModal } from "./ChestOpenModal";
|
|
|
|
import { ChestOpenModal } from "./ChestOpenModal";
|
|
|
|
// Re-use the same theme generator that QuestMap uses so arc colours are consistent
|
|
|
|
|
|
|
|
import { generateArcTheme } from "../pages/student/QuestMap";
|
|
|
|
import { generateArcTheme } from "../pages/student/QuestMap";
|
|
|
|
import { InventoryButton } from "./InventoryButton";
|
|
|
|
import { InventoryButton } from "./InventoryButton";
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Requirement helpers (mirrors QuestMap) ───────────────────────────────────
|
|
|
|
// ─── Requirement helpers ──────────────────────────────────────────────────────
|
|
|
|
const REQ_EMOJI: Record<string, string> = {
|
|
|
|
const REQ_EMOJI: Record<string, string> = {
|
|
|
|
questions: "❓",
|
|
|
|
questions: "❓",
|
|
|
|
accuracy: "🎯",
|
|
|
|
accuracy: "🎯",
|
|
|
|
@ -256,37 +255,93 @@ const STYLES = `
|
|
|
|
border: 1px solid rgba(251,191,36,0.18); border-radius: 100px;
|
|
|
|
border: 1px solid rgba(251,191,36,0.18); border-radius: 100px;
|
|
|
|
padding: 0.2rem 0.6rem;
|
|
|
|
padding: 0.2rem 0.6rem;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Rank ladder scroll container ──
|
|
|
|
|
|
|
|
Always scrollable on mobile. On desktop (≥1024px) we lock the width and
|
|
|
|
|
|
|
|
disable scrolling — nodes are spaced by percentage so no overflow occurs. */
|
|
|
|
.hc-ext-scroll {
|
|
|
|
.hc-ext-scroll {
|
|
|
|
position: relative; z-index: 2;
|
|
|
|
overflow-x: auto;
|
|
|
|
overflow-x: auto; overflow-y: hidden;
|
|
|
|
overflow-y: hidden;
|
|
|
|
-webkit-overflow-scrolling: touch; scrollbar-width: none;
|
|
|
|
scrollbar-width: none;
|
|
|
|
cursor: grab; padding: 1.0rem 1.0rem 0.8rem;
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
|
|
|
cursor: grab;
|
|
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
z-index: 2;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.hc-ext-scroll::-webkit-scrollbar { display: none; }
|
|
|
|
.hc-ext-scroll::-webkit-scrollbar { display: none; }
|
|
|
|
.hc-ext-scroll:active { cursor: grabbing; }
|
|
|
|
.hc-ext-scroll:active { cursor: grabbing; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Rank ladder inner track ──
|
|
|
|
|
|
|
|
On mobile: fixed pixel width (fits all 6 nodes without squishing).
|
|
|
|
|
|
|
|
On desktop: 100% width, nodes spaced purely by percentage. */
|
|
|
|
.hc-ext-inner {
|
|
|
|
.hc-ext-inner {
|
|
|
|
display: flex; align-items: flex-end;
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
position: relative;
|
|
|
|
position: relative;
|
|
|
|
height: 110px;
|
|
|
|
height: 110px;
|
|
|
|
|
|
|
|
/* Mobile: fixed width so nodes have room and scroll kicks in */
|
|
|
|
|
|
|
|
width: 520px;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@media (min-width: 1024px) {
|
|
|
|
|
|
|
|
.hc-ext-scroll {
|
|
|
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
|
|
|
cursor: default;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.hc-ext-inner {
|
|
|
|
|
|
|
|
/* Desktop: fill the card width; percentage positions work correctly */
|
|
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
/* Upscale fonts for desktop readability */
|
|
|
|
|
|
|
|
.hc-ext-title { font-size: 0.72rem; }
|
|
|
|
|
|
|
|
.hc-ext-earned { font-size: 0.82rem; }
|
|
|
|
|
|
|
|
.hc-ext-label-name { font-size: 0.56rem; }
|
|
|
|
|
|
|
|
.hc-ext-label-xp { font-size: 0.5rem; }
|
|
|
|
|
|
|
|
.hc-ext-node { width: 56px; height: 56px; font-size: 1.5rem; }
|
|
|
|
|
|
|
|
.hc-ext-ship { font-size: 1.7rem; }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Baseline and progress line ──
|
|
|
|
|
|
|
|
Use percentage-based left/right so they align with percentage-positioned nodes
|
|
|
|
|
|
|
|
regardless of container width. A small inset (half a node width as %) keeps
|
|
|
|
|
|
|
|
the line from starting/ending at the very edge of the outer nodes. */
|
|
|
|
.hc-ext-baseline {
|
|
|
|
.hc-ext-baseline {
|
|
|
|
position: absolute;
|
|
|
|
position: absolute;
|
|
|
|
top: 56px; left: 26px; right: 26px; height: 2px;
|
|
|
|
top: 56px;
|
|
|
|
|
|
|
|
/* Inset matches half the outer col width relative to inner width */
|
|
|
|
|
|
|
|
left: 4%;
|
|
|
|
|
|
|
|
right: 4%;
|
|
|
|
|
|
|
|
height: 2px;
|
|
|
|
background: rgba(255,255,255,0.07);
|
|
|
|
background: rgba(255,255,255,0.07);
|
|
|
|
border-radius: 2px; z-index: 0;
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
|
|
z-index: 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.hc-ext-progress-line {
|
|
|
|
.hc-ext-progress-line {
|
|
|
|
position: absolute;
|
|
|
|
position: absolute;
|
|
|
|
top: 56px; left: 26px; height: 2px;
|
|
|
|
top: 56px;
|
|
|
|
|
|
|
|
left: 4%;
|
|
|
|
|
|
|
|
height: 2px;
|
|
|
|
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
|
|
|
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
|
|
|
box-shadow: 0 0 10px rgba(251,191,36,0.5);
|
|
|
|
box-shadow: 0 0 10px rgba(251,191,36,0.5);
|
|
|
|
border-radius: 2px; z-index: 1;
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
|
|
z-index: 1;
|
|
|
|
|
|
|
|
/* Width is set inline as a % of the 92% usable span (100% - 4% - 4%) */
|
|
|
|
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
|
|
|
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.hc-ext-progress-line::after {
|
|
|
|
|
|
|
|
content: "";
|
|
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
|
|
right: -6px; top: -3px;
|
|
|
|
|
|
|
|
width: 10px; height: 10px;
|
|
|
|
|
|
|
|
background: #fbbf24;
|
|
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
|
|
box-shadow: 0 0 10px rgba(251,191,36,0.9);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hc-ext-ship-wrap {
|
|
|
|
.hc-ext-ship-wrap {
|
|
|
|
position: absolute;
|
|
|
|
position: absolute;
|
|
|
|
top: 25px; z-index: 10; pointer-events: none;
|
|
|
|
top: 20px; z-index: 10; pointer-events: none;
|
|
|
|
display: flex; flex-direction: column; align-items: center; gap: 0px;
|
|
|
|
display: flex; flex-direction: column; align-items: center;
|
|
|
|
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
|
|
|
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
|
|
|
transform: translateX(-50%);
|
|
|
|
transform: translateX(-50%);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -304,18 +359,23 @@ const STYLES = `
|
|
|
|
width: 1px; height: 14px;
|
|
|
|
width: 1px; height: 14px;
|
|
|
|
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
|
|
|
|
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Node columns ──
|
|
|
|
|
|
|
|
Each col takes an equal share; absolute positioning within .hc-ext-inner
|
|
|
|
|
|
|
|
is replaced by evenly-spaced flex. The node itself is centred in the col. */
|
|
|
|
.hc-ext-col {
|
|
|
|
.hc-ext-col {
|
|
|
|
display: flex; flex-direction: column; align-items: center;
|
|
|
|
flex: 1;
|
|
|
|
position: relative; z-index: 2;
|
|
|
|
display: flex;
|
|
|
|
width: 88px; flex-shrink: 0;
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
z-index: 2;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.hc-ext-col:first-child,
|
|
|
|
|
|
|
|
.hc-ext-col:last-child { width: 52px; }
|
|
|
|
|
|
|
|
.hc-ext-node {
|
|
|
|
.hc-ext-node {
|
|
|
|
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
|
|
|
|
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
|
font-size: 1.4rem; position: relative; z-index: 2;
|
|
|
|
font-size: 1.4rem; position: relative; z-index: 2;
|
|
|
|
margin-top: 42px;
|
|
|
|
margin-top: 30px;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.hc-ext-node.reached {
|
|
|
|
.hc-ext-node.reached {
|
|
|
|
background: linear-gradient(145deg, #1e0e4a, #3730a3);
|
|
|
|
background: linear-gradient(145deg, #1e0e4a, #3730a3);
|
|
|
|
@ -377,7 +437,6 @@ function getActiveQuests(arcs: QuestArc[]) {
|
|
|
|
for (const node of arc.nodes)
|
|
|
|
for (const node of arc.nodes)
|
|
|
|
if (node.status === "claimable" || node.status === "active")
|
|
|
|
if (node.status === "claimable" || node.status === "active")
|
|
|
|
out.push({ node, arc });
|
|
|
|
out.push({ node, arc });
|
|
|
|
// Claimable nodes bubble to the top
|
|
|
|
|
|
|
|
out.sort((a, b) =>
|
|
|
|
out.sort((a, b) =>
|
|
|
|
a.node.status === "claimable" && b.node.status !== "claimable"
|
|
|
|
a.node.status === "claimable" && b.node.status !== "claimable"
|
|
|
|
? -1
|
|
|
|
? -1
|
|
|
|
@ -388,14 +447,6 @@ function getActiveQuests(arcs: QuestArc[]) {
|
|
|
|
return out.slice(0, 2);
|
|
|
|
return out.slice(0, 2);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const SEG_W = 88;
|
|
|
|
|
|
|
|
const EDGE_W = 52;
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
return EDGE_W + SEG_W * (i - 1) + SEG_W / 2;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
|
|
|
|
// ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
|
|
|
|
const RankLadder = ({
|
|
|
|
const RankLadder = ({
|
|
|
|
earnedXP,
|
|
|
|
earnedXP,
|
|
|
|
@ -425,12 +476,25 @@ const RankLadder = ({
|
|
|
|
)
|
|
|
|
)
|
|
|
|
: 1;
|
|
|
|
: 1;
|
|
|
|
|
|
|
|
|
|
|
|
const shipX = nextRank
|
|
|
|
// ── Geometry ────────────────────────────────────────────────────────────────
|
|
|
|
? nodeX(currentIdx, N) +
|
|
|
|
// Nodes are evenly distributed via flex (each col = flex:1).
|
|
|
|
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext
|
|
|
|
// The centre of node[i] sits at: leftInset + (i / (N-1)) * usableSpan
|
|
|
|
: nodeX(currentIdx, N);
|
|
|
|
// where leftInset = 4% and usableSpan = 92% (100% - 4% left - 4% right).
|
|
|
|
const progressLineW = shipX;
|
|
|
|
// The baseline and progress line also start at 4% so everything aligns.
|
|
|
|
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
|
|
|
|
const LEFT_INSET_PCT = 4; // matches left: 4% on baseline/progress
|
|
|
|
|
|
|
|
const USABLE_PCT = 92; // 100 - 4 - 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Ship position as % of the inner container width
|
|
|
|
|
|
|
|
const nodePosPct = (i: number) => LEFT_INSET_PCT + (i / (N - 1)) * USABLE_PCT;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const shipPct = nextRank
|
|
|
|
|
|
|
|
? nodePosPct(currentIdx) +
|
|
|
|
|
|
|
|
(nodePosPct(currentIdx + 1) - nodePosPct(currentIdx)) * progressToNext
|
|
|
|
|
|
|
|
: nodePosPct(currentIdx);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Progress line width: distance from left inset to ship position
|
|
|
|
|
|
|
|
// expressed as % of the container (not of usable span)
|
|
|
|
|
|
|
|
const progressLinePct = shipPct - LEFT_INSET_PCT;
|
|
|
|
|
|
|
|
|
|
|
|
const [animated, setAnimated] = useState(false);
|
|
|
|
const [animated, setAnimated] = useState(false);
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
@ -440,14 +504,36 @@ const RankLadder = ({
|
|
|
|
return () => cancelAnimationFrame(id);
|
|
|
|
return () => cancelAnimationFrame(id);
|
|
|
|
}, []);
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Mouse-drag scroll for mobile
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (!scrollRef.current) return;
|
|
|
|
|
|
|
|
const el = scrollRef.current;
|
|
|
|
const el = scrollRef.current;
|
|
|
|
el.scrollTo({
|
|
|
|
if (!el) return;
|
|
|
|
left: Math.max(0, shipX - el.offsetWidth / 2),
|
|
|
|
let isDown = false,
|
|
|
|
behavior: "smooth",
|
|
|
|
startX = 0,
|
|
|
|
});
|
|
|
|
scrollLeft = 0;
|
|
|
|
}, [shipX]);
|
|
|
|
const down = (e: MouseEvent) => {
|
|
|
|
|
|
|
|
isDown = true;
|
|
|
|
|
|
|
|
startX = e.pageX - el.offsetLeft;
|
|
|
|
|
|
|
|
scrollLeft = el.scrollLeft;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
const leave = () => (isDown = false);
|
|
|
|
|
|
|
|
const up = () => (isDown = false);
|
|
|
|
|
|
|
|
const move = (e: MouseEvent) => {
|
|
|
|
|
|
|
|
if (!isDown) return;
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
el.scrollLeft = scrollLeft - (e.pageX - el.offsetLeft - startX) * 1.2;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
el.addEventListener("mousedown", down);
|
|
|
|
|
|
|
|
el.addEventListener("mouseleave", leave);
|
|
|
|
|
|
|
|
el.addEventListener("mouseup", up);
|
|
|
|
|
|
|
|
el.addEventListener("mousemove", move);
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
|
|
el.removeEventListener("mousedown", down);
|
|
|
|
|
|
|
|
el.removeEventListener("mouseleave", leave);
|
|
|
|
|
|
|
|
el.removeEventListener("mouseup", up);
|
|
|
|
|
|
|
|
el.removeEventListener("mousemove", move);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
|
|
|
|
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
|
|
|
|
const nextLabel = nextRank
|
|
|
|
const nextLabel = nextRank
|
|
|
|
@ -495,21 +581,28 @@ const RankLadder = ({
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hc-ext-scroll" ref={scrollRef}>
|
|
|
|
<div className="hc-ext-scroll" ref={scrollRef}>
|
|
|
|
<div className="hc-ext-inner" style={{ width: totalW }}>
|
|
|
|
<div className="hc-ext-inner">
|
|
|
|
|
|
|
|
{/* Baseline — left: 4%, right: 4% (set in CSS) */}
|
|
|
|
<div className="hc-ext-baseline" />
|
|
|
|
<div className="hc-ext-baseline" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Progress line — starts at left: 4%, width grows to ship position */}
|
|
|
|
<div
|
|
|
|
<div
|
|
|
|
className="hc-ext-progress-line"
|
|
|
|
className="hc-ext-progress-line"
|
|
|
|
style={{ width: animated ? progressLineW : 26 }}
|
|
|
|
style={{ width: animated ? `${progressLinePct}%` : "0%" }}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Ship — positioned as % of container */}
|
|
|
|
<div
|
|
|
|
<div
|
|
|
|
className="hc-ext-ship-wrap"
|
|
|
|
className="hc-ext-ship-wrap"
|
|
|
|
style={{ left: animated ? shipX : nodeX(0, N) }}
|
|
|
|
style={{ left: animated ? `${shipPct}%` : `${nodePosPct(0)}%` }}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<span className="hc-ext-ship" role="img" aria-label="ship">
|
|
|
|
<span className="hc-ext-ship" role="img" aria-label="ship">
|
|
|
|
⛵
|
|
|
|
⛵
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
<div className="hc-ext-ship-tether" />
|
|
|
|
<div className="hc-ext-ship-tether" />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Nodes — evenly spaced via flex:1 on each col */}
|
|
|
|
{ladder.map((r, i) => {
|
|
|
|
{ladder.map((r, i) => {
|
|
|
|
const state =
|
|
|
|
const state =
|
|
|
|
i < currentIdx
|
|
|
|
i < currentIdx
|
|
|
|
@ -551,14 +644,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
const navigate = useNavigate();
|
|
|
|
const navigate = useNavigate();
|
|
|
|
const user = useAuthStore((s) => s.user);
|
|
|
|
const user = useAuthStore((s) => s.user);
|
|
|
|
|
|
|
|
|
|
|
|
// Select all needed store slices — earnedXP and earnedTitles are now first-class state
|
|
|
|
|
|
|
|
const arcs = useQuestStore((s) => s.arcs);
|
|
|
|
const arcs = useQuestStore((s) => s.arcs);
|
|
|
|
const earnedXP = user?.total_xp ?? 0;
|
|
|
|
const earnedXP = user?.total_xp ?? 0;
|
|
|
|
const earnedTitles = useQuestStore((s) => s.earnedTitles);
|
|
|
|
const earnedTitles = useQuestStore((s) => s.earnedTitles);
|
|
|
|
const claimNode = useQuestStore((s) => s.claimNode);
|
|
|
|
const claimNode = useQuestStore((s) => s.claimNode);
|
|
|
|
|
|
|
|
|
|
|
|
// Updated signatures: getQuestSummary needs earnedXP + earnedTitles,
|
|
|
|
|
|
|
|
// getCrewRank takes earnedXP directly (no longer iterates nodes)
|
|
|
|
|
|
|
|
const summary = getQuestSummary(arcs, earnedXP, earnedTitles);
|
|
|
|
const summary = getQuestSummary(arcs, earnedXP, earnedTitles);
|
|
|
|
const rank = getCrewRank(earnedXP);
|
|
|
|
const rank = getCrewRank(earnedXP);
|
|
|
|
const activeQuests = getActiveQuests(arcs);
|
|
|
|
const activeQuests = getActiveQuests(arcs);
|
|
|
|
@ -598,7 +688,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
node: QuestNode;
|
|
|
|
node: QuestNode;
|
|
|
|
arcId: string;
|
|
|
|
arcId: string;
|
|
|
|
} | null>(null);
|
|
|
|
} | null>(null);
|
|
|
|
// Holds the API response from the claim call so ChestOpenModal can display real rewards
|
|
|
|
|
|
|
|
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
|
|
|
|
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
|
|
|
|
null,
|
|
|
|
null,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
@ -609,7 +698,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleClaim = (node: QuestNode, arcId: string) => {
|
|
|
|
const handleClaim = (node: QuestNode, arcId: string) => {
|
|
|
|
setClaimResult(null); // clear any previous result before opening
|
|
|
|
setClaimResult(null);
|
|
|
|
setClaimingNode({ node, arcId });
|
|
|
|
setClaimingNode({ node, arcId });
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
@ -617,7 +706,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
if (!claimingNode) return;
|
|
|
|
if (!claimingNode) return;
|
|
|
|
claimNode(
|
|
|
|
claimNode(
|
|
|
|
claimingNode.arcId,
|
|
|
|
claimingNode.arcId,
|
|
|
|
claimingNode.node.node_id, // node_id replaces old id
|
|
|
|
claimingNode.node.node_id,
|
|
|
|
claimResult?.xp_awarded ?? 0,
|
|
|
|
claimResult?.xp_awarded ?? 0,
|
|
|
|
claimResult?.title_unlocked.map((t) => t.name) ?? [],
|
|
|
|
claimResult?.title_unlocked.map((t) => t.name) ?? [],
|
|
|
|
);
|
|
|
|
);
|
|
|
|
@ -656,7 +745,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
<style>{STYLES}</style>
|
|
|
|
<style>{STYLES}</style>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hc-card">
|
|
|
|
<div className="hc-card">
|
|
|
|
{/* Identity — DEFAULT only */}
|
|
|
|
|
|
|
|
{showIdentity && (
|
|
|
|
{showIdentity && (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<div className="hc-top">
|
|
|
|
<div className="hc-top">
|
|
|
|
@ -697,12 +785,10 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
</DrawerContent>
|
|
|
|
</DrawerContent>
|
|
|
|
</Drawer>
|
|
|
|
</Drawer>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hc-sep" />
|
|
|
|
<div className="hc-sep" />
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* XP bar — DEFAULT + LEVEL */}
|
|
|
|
|
|
|
|
{showLevel && (
|
|
|
|
{showLevel && (
|
|
|
|
<div className="hc-xp-row">
|
|
|
|
<div className="hc-xp-row">
|
|
|
|
<span className="hc-lvl-tag">Lv {level}</span>
|
|
|
|
<span className="hc-lvl-tag">Lv {level}</span>
|
|
|
|
@ -718,7 +804,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Rank + collapsible quests — DEFAULT + QUEST_COMPACT */}
|
|
|
|
|
|
|
|
{showQuestCompact && (
|
|
|
|
{showQuestCompact && (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<div className="hc-rank-row" onClick={() => setOpen((o) => !o)}>
|
|
|
|
<div className="hc-rank-row" onClick={() => setOpen((o) => !o)}>
|
|
|
|
@ -749,22 +834,17 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
<p className="hc-empty">⚓ All caught up — keep sailing!</p>
|
|
|
|
<p className="hc-empty">⚓ All caught up — keep sailing!</p>
|
|
|
|
) : (
|
|
|
|
) : (
|
|
|
|
activeQuests.map(({ node, arc }) => {
|
|
|
|
activeQuests.map(({ node, arc }) => {
|
|
|
|
// Progress uses new field names
|
|
|
|
|
|
|
|
const pct = Math.min(
|
|
|
|
const pct = Math.min(
|
|
|
|
100,
|
|
|
|
100,
|
|
|
|
Math.round((node.current_value / node.req_target) * 100),
|
|
|
|
Math.round((node.current_value / node.req_target) * 100),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
const isClaimable = node.status === "claimable";
|
|
|
|
const isClaimable = node.status === "claimable";
|
|
|
|
// Arc accent colour via theme generator — arc.accentColor no longer exists
|
|
|
|
|
|
|
|
const accentColor = generateArcTheme(arc).accent;
|
|
|
|
const accentColor = generateArcTheme(arc).accent;
|
|
|
|
// Node icon derived from req_type — node.emoji no longer exists
|
|
|
|
|
|
|
|
const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️";
|
|
|
|
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;
|
|
|
|
const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
<div
|
|
|
|
key={node.node_id} // node_id replaces old id
|
|
|
|
key={node.node_id}
|
|
|
|
className="hc-quest-row"
|
|
|
|
className="hc-quest-row"
|
|
|
|
style={{ "--ac": accentColor } as React.CSSProperties}
|
|
|
|
style={{ "--ac": accentColor } as React.CSSProperties}
|
|
|
|
onClick={() => !isClaimable && handleViewAll()}
|
|
|
|
onClick={() => !isClaimable && handleViewAll()}
|
|
|
|
@ -775,13 +855,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
|
|
|
{isClaimable ? "📦" : nodeEmoji}
|
|
|
|
{isClaimable ? "📦" : nodeEmoji}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="hc-q-body">
|
|
|
|
<div className="hc-q-body">
|
|
|
|
{/* node.name replaces old node.title */}
|
|
|
|
|
|
|
|
<p className="hc-q-name">{node.name ?? "—"}</p>
|
|
|
|
<p className="hc-q-name">{node.name ?? "—"}</p>
|
|
|
|
{isClaimable ? (
|
|
|
|
{isClaimable ? (
|
|
|
|
<p className="hc-q-claimable">✨ Ready to claim!</p>
|
|
|
|
<p className="hc-q-claimable">✨ Ready to claim!</p>
|
|
|
|
) : (
|
|
|
|
) : (
|
|
|
|
<p className="hc-q-sub">
|
|
|
|
<p className="hc-q-sub">
|
|
|
|
{/* current_value / req_target replace old progress / requirement.target */}
|
|
|
|
|
|
|
|
{node.current_value}/{node.req_target} {reqLabel}{" "}
|
|
|
|
{node.current_value}/{node.req_target} {reqLabel}{" "}
|
|
|
|
· {pct}%
|
|
|
|
· {pct}%
|
|
|
|
</p>
|
|
|
|
</p>
|
|
|
|
|