fix(api): fix api integration for quest map and adjacent components
This commit is contained in:
@ -6,14 +6,41 @@ import {
|
||||
useQuestStore,
|
||||
getQuestSummary,
|
||||
getCrewRank,
|
||||
getEarnedXP,
|
||||
} from "../stores/useQuestStore";
|
||||
import type { QuestNode, QuestArc } from "../types/quest";
|
||||
import type {
|
||||
QuestNode,
|
||||
QuestArc,
|
||||
ClaimedRewardResponse,
|
||||
} from "../types/quest";
|
||||
import { CREW_RANKS } from "../types/quest";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
|
||||
import { PredictedScoreCard } from "./PredictedScoreCard";
|
||||
import { ChestOpenModal } from "./ChestOpenModal";
|
||||
// Re-use the same theme generator that QuestMap uses so arc colours are consistent
|
||||
import { generateArcTheme } from "../pages/student/QuestMap";
|
||||
import { InventoryButton } from "./InventoryButton";
|
||||
|
||||
// ─── Requirement helpers (mirrors QuestMap) ───────────────────────────────────
|
||||
const REQ_EMOJI: Record<string, string> = {
|
||||
questions: "❓",
|
||||
accuracy: "🎯",
|
||||
streak: "🔥",
|
||||
sessions: "📚",
|
||||
topics: "🗺️",
|
||||
xp: "⚡",
|
||||
leaderboard: "🏆",
|
||||
};
|
||||
|
||||
const REQ_LABEL: Record<string, string> = {
|
||||
questions: "questions answered",
|
||||
accuracy: "% accuracy",
|
||||
streak: "day streak",
|
||||
sessions: "sessions",
|
||||
topics: "topics covered",
|
||||
xp: "XP earned",
|
||||
leaderboard: "leaderboard rank",
|
||||
};
|
||||
|
||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||
const STYLES = `
|
||||
@ -196,8 +223,6 @@ const STYLES = `
|
||||
animation: hcIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Animated sea shimmer */
|
||||
.hc-ext::before {
|
||||
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
||||
background:
|
||||
@ -210,16 +235,12 @@ const STYLES = `
|
||||
0% { background-position: 0% 0%, 100% 0%; }
|
||||
100% { background-position: 100% 100%, 0% 100%; }
|
||||
}
|
||||
|
||||
/* Gold orb */
|
||||
.hc-ext::after {
|
||||
content: ''; position: absolute; top: -40px; right: -30px; z-index: 0;
|
||||
width: 180px; height: 180px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(251,191,36,0.1), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.hc-ext-header {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
@ -235,8 +256,6 @@ const STYLES = `
|
||||
border: 1px solid rgba(251,191,36,0.18); border-radius: 100px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Scrollable track container */
|
||||
.hc-ext-scroll {
|
||||
position: relative; z-index: 2;
|
||||
overflow-x: auto; overflow-y: hidden;
|
||||
@ -245,26 +264,17 @@ const STYLES = `
|
||||
}
|
||||
.hc-ext-scroll::-webkit-scrollbar { display: none; }
|
||||
.hc-ext-scroll:active { cursor: grabbing; }
|
||||
|
||||
/* Track inner wrapper — the thing that actually lays out rank nodes */
|
||||
.hc-ext-inner {
|
||||
display: flex; align-items: flex-end;
|
||||
position: relative;
|
||||
/* height: ship(28px) + gap(14px) + node(52px) + label(36px) = ~130px */
|
||||
height: 110px;
|
||||
/* width set inline per node count */
|
||||
}
|
||||
|
||||
/* Baseline connector line — full width, dim */
|
||||
.hc-ext-baseline {
|
||||
position: absolute;
|
||||
top: 56px; /* ship(28) + gap(14) + half of node(26) — sits at node centre */
|
||||
left: 26px; right: 26px; height: 2px;
|
||||
top: 56px; left: 26px; right: 26px; height: 2px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border-radius: 2px; z-index: 0;
|
||||
}
|
||||
|
||||
/* Gold progress line — width set inline */
|
||||
.hc-ext-progress-line {
|
||||
position: absolute;
|
||||
top: 56px; left: 26px; height: 2px;
|
||||
@ -273,12 +283,9 @@ const STYLES = `
|
||||
border-radius: 2px; z-index: 1;
|
||||
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
|
||||
/* Ship — absolutely positioned, transition on 'left' */
|
||||
.hc-ext-ship-wrap {
|
||||
position: absolute;
|
||||
top: 25px; /* sits at top of inner, ship 28px + gap 14px = 42px to node top (56px centre) */
|
||||
z-index: 10; pointer-events: none;
|
||||
top: 25px; z-index: 10; pointer-events: none;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0px;
|
||||
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
|
||||
transform: translateX(-50%);
|
||||
@ -297,23 +304,18 @@ const STYLES = `
|
||||
width: 1px; height: 14px;
|
||||
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
|
||||
}
|
||||
|
||||
/* Each rank column */
|
||||
.hc-ext-col {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
position: relative; z-index: 2;
|
||||
width: 88px; flex-shrink: 0;
|
||||
}
|
||||
/* Narrow first/last columns so line extends correctly */
|
||||
.hc-ext-col:first-child,
|
||||
.hc-ext-col:last-child { width: 52px; }
|
||||
|
||||
/* Node circle */
|
||||
.hc-ext-node {
|
||||
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; position: relative; z-index: 2;
|
||||
margin-top: 42px; /* push down below ship zone */
|
||||
margin-top: 42px;
|
||||
}
|
||||
.hc-ext-node.reached {
|
||||
background: linear-gradient(145deg, #1e0e4a, #3730a3);
|
||||
@ -334,12 +336,10 @@ const STYLES = `
|
||||
50% { box-shadow: 0 0 0 7px rgba(251,191,36,0.06), 0 0 30px rgba(168,85,247,0.6), 0 4px 0 rgba(80,30,150,0.5); }
|
||||
}
|
||||
.hc-ext-node.locked {
|
||||
background: rgba(0,0,0);
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: 2px solid rgba(255,255,255,0.09);
|
||||
filter: grayscale(0.7) opacity(0.45);
|
||||
}
|
||||
|
||||
/* Labels below node */
|
||||
.hc-ext-label {
|
||||
margin-top: 7px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
@ -358,8 +358,6 @@ const STYLES = `
|
||||
.hc-ext-label-xp.reached { color: rgba(251,191,36,0.4); }
|
||||
.hc-ext-label-xp.current { color: rgba(192,132,252,0.6); }
|
||||
.hc-ext-label-xp.locked { color: rgba(255,255,255,0.15); }
|
||||
|
||||
/* Footer link */
|
||||
.hc-ext-footer {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
|
||||
@ -372,13 +370,14 @@ const STYLES = `
|
||||
.hc-ext-footer:hover { opacity: 0.75; }
|
||||
`;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function getActiveQuests(arcs: QuestArc[]) {
|
||||
const out: { node: QuestNode; arc: QuestArc }[] = [];
|
||||
for (const arc of arcs)
|
||||
for (const node of arc.nodes)
|
||||
if (node.status === "claimable" || node.status === "active")
|
||||
out.push({ node, arc });
|
||||
// Claimable nodes bubble to the top
|
||||
out.sort((a, b) =>
|
||||
a.node.status === "claimable" && b.node.status !== "claimable"
|
||||
? -1
|
||||
@ -389,10 +388,8 @@ function getActiveQuests(arcs: QuestArc[]) {
|
||||
return out.slice(0, 2);
|
||||
}
|
||||
|
||||
// Segment width for nodes that aren't first/last
|
||||
const SEG_W = 88;
|
||||
const EDGE_W = 52;
|
||||
// Centre x of node at index i (0-based, total N nodes)
|
||||
function nodeX(i: number, total: number): number {
|
||||
if (i === 0) return EDGE_W / 2;
|
||||
if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
|
||||
@ -402,7 +399,6 @@ function nodeX(i: number, total: number): number {
|
||||
// ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
|
||||
const RankLadder = ({
|
||||
earnedXP,
|
||||
onViewAll,
|
||||
}: {
|
||||
earnedXP: number;
|
||||
onViewAll: () => void;
|
||||
@ -411,7 +407,6 @@ const RankLadder = ({
|
||||
const ladder = [...CREW_RANKS] as typeof CREW_RANKS;
|
||||
const N = ladder.length;
|
||||
|
||||
// Which rank the user is currently on (0-based)
|
||||
let currentIdx = 0;
|
||||
for (let i = N - 1; i >= 0; i--) {
|
||||
if (earnedXP >= ladder[i].xpRequired) {
|
||||
@ -430,19 +425,13 @@ const RankLadder = ({
|
||||
)
|
||||
: 1;
|
||||
|
||||
// Ship x position: interpolate between current node and next node
|
||||
const shipX = nextRank
|
||||
? nodeX(currentIdx, N) +
|
||||
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext
|
||||
: nodeX(currentIdx, N);
|
||||
|
||||
// Gold progress line width: from left edge to ship position
|
||||
const progressLineW = shipX;
|
||||
|
||||
// Total scroll width
|
||||
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
|
||||
|
||||
// Animate ship in after mount
|
||||
const [animated, setAnimated] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() =>
|
||||
@ -451,13 +440,13 @@ const RankLadder = ({
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to ship position on mount
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
const containerW = el.offsetWidth;
|
||||
const targetScroll = shipX - containerW / 2;
|
||||
el.scrollTo({ left: Math.max(0, targetScroll), behavior: "smooth" });
|
||||
el.scrollTo({
|
||||
left: Math.max(0, shipX - el.offsetWidth / 2),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [shipX]);
|
||||
|
||||
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
|
||||
@ -467,13 +456,11 @@ const RankLadder = ({
|
||||
|
||||
return (
|
||||
<div className="hc-ext">
|
||||
{/* Header */}
|
||||
<div className="hc-ext-header">
|
||||
<span className="hc-ext-title">⚓ Crew Rank</span>
|
||||
<span className="hc-ext-earned">{earnedXP.toLocaleString()} XP</span>
|
||||
</div>
|
||||
|
||||
{/* Current rank label */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
@ -507,19 +494,13 @@ const RankLadder = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scrollable rank track */}
|
||||
<div className="hc-ext-scroll" ref={scrollRef}>
|
||||
<div className="hc-ext-inner" style={{ width: totalW }}>
|
||||
{/* Baseline dim line */}
|
||||
<div className="hc-ext-baseline" />
|
||||
|
||||
{/* Gold progress line */}
|
||||
<div
|
||||
className="hc-ext-progress-line"
|
||||
style={{ width: animated ? progressLineW : 26 }}
|
||||
/>
|
||||
|
||||
{/* Ship marker */}
|
||||
<div
|
||||
className="hc-ext-ship-wrap"
|
||||
style={{ left: animated ? shipX : nodeX(0, N) }}
|
||||
@ -529,8 +510,6 @@ const RankLadder = ({
|
||||
</span>
|
||||
<div className="hc-ext-ship-tether" />
|
||||
</div>
|
||||
|
||||
{/* Rank nodes */}
|
||||
{ladder.map((r, i) => {
|
||||
const state =
|
||||
i < currentIdx
|
||||
@ -556,19 +535,12 @@ const RankLadder = ({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* <div className="hc-ext-footer" onClick={onViewAll}>
|
||||
<Map size={12} />
|
||||
View quest map
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
type Mode = "DEFAULT" | "LEVEL" | "QUEST_COMPACT" | "QUEST_EXTENDED";
|
||||
|
||||
interface Props {
|
||||
onViewAll?: () => void;
|
||||
mode?: Mode;
|
||||
@ -578,17 +550,22 @@ interface Props {
|
||||
export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
// Select all needed store slices — earnedXP and earnedTitles are now first-class state
|
||||
const arcs = useQuestStore((s) => s.arcs);
|
||||
const earnedXP = user?.total_xp ?? 0;
|
||||
const earnedTitles = useQuestStore((s) => s.earnedTitles);
|
||||
const claimNode = useQuestStore((s) => s.claimNode);
|
||||
|
||||
const summary = getQuestSummary(arcs);
|
||||
const rank = getCrewRank(arcs);
|
||||
const earnedXP = getEarnedXP(arcs);
|
||||
// Updated signatures: getQuestSummary needs earnedXP + earnedTitles,
|
||||
// getCrewRank takes earnedXP directly (no longer iterates nodes)
|
||||
const summary = getQuestSummary(arcs, earnedXP, earnedTitles);
|
||||
const rank = getCrewRank(earnedXP);
|
||||
const activeQuests = getActiveQuests(arcs);
|
||||
|
||||
const u = user as any;
|
||||
const level = u?.current_level ?? u?.level ?? 1;
|
||||
const totalXP = u?.total_xp ?? u?.xp ?? 0;
|
||||
const level = u?.current_level ?? 1;
|
||||
const totalXP = u?.total_xp ?? 5;
|
||||
const levelStart = u?.current_level_start ?? u?.level_min_xp ?? 0;
|
||||
const levelEnd =
|
||||
u?.next_level_threshold ?? u?.level_max_xp ?? levelStart + 1000;
|
||||
@ -621,17 +598,31 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
node: QuestNode;
|
||||
arcId: string;
|
||||
} | null>(null);
|
||||
// Holds the API response from the claim call so ChestOpenModal can display real rewards
|
||||
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleViewAll = () => {
|
||||
if (onViewAll) onViewAll();
|
||||
else navigate("/student/quests");
|
||||
};
|
||||
const handleClaim = (node: QuestNode, arcId: string) =>
|
||||
|
||||
const handleClaim = (node: QuestNode, arcId: string) => {
|
||||
setClaimResult(null); // clear any previous result before opening
|
||||
setClaimingNode({ node, arcId });
|
||||
};
|
||||
|
||||
const handleChestClose = () => {
|
||||
if (!claimingNode) return;
|
||||
claimNode(claimingNode.arcId, claimingNode.node.id);
|
||||
claimNode(
|
||||
claimingNode.arcId,
|
||||
claimingNode.node.node_id, // node_id replaces old id
|
||||
claimResult?.xp_awarded ?? 0,
|
||||
claimResult?.title_unlocked.map((t) => t.name) ?? [],
|
||||
);
|
||||
setClaimingNode(null);
|
||||
setClaimResult(null);
|
||||
};
|
||||
|
||||
const rankProgress = Math.round(rank.progressToNext * 100);
|
||||
@ -644,14 +635,17 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
const showQuestCompact = mode === "DEFAULT" || mode === "QUEST_COMPACT";
|
||||
const showQuestExtended = mode === "QUEST_EXTENDED";
|
||||
|
||||
// QUEST_EXTENDED renders its own standalone dark card — no .hc-card wrapper
|
||||
if (showQuestExtended) {
|
||||
return (
|
||||
<>
|
||||
<style>{STYLES}</style>
|
||||
<RankLadder earnedXP={earnedXP} onViewAll={handleViewAll} />
|
||||
{claimingNode && (
|
||||
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
||||
<ChestOpenModal
|
||||
node={claimingNode.node}
|
||||
claimResult={claimResult}
|
||||
onClose={handleChestClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@ -691,10 +685,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
<p className="hc-role">{roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<InventoryButton label="Inventory" />
|
||||
<Drawer direction="top">
|
||||
<DrawerTrigger asChild>
|
||||
<button className="hc-score-btn">
|
||||
<Gauge size={14} /> Score
|
||||
<Gauge size={14} />
|
||||
</button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
@ -702,6 +697,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
<div className="hc-sep" />
|
||||
</>
|
||||
)}
|
||||
@ -753,35 +749,41 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
<p className="hc-empty">⚓ All caught up — keep sailing!</p>
|
||||
) : (
|
||||
activeQuests.map(({ node, arc }) => {
|
||||
// Progress uses new field names
|
||||
const pct = Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(node.progress / node.requirement.target) * 100,
|
||||
),
|
||||
Math.round((node.current_value / node.req_target) * 100),
|
||||
);
|
||||
const isClaimable = node.status === "claimable";
|
||||
// Arc accent colour via theme generator — arc.accentColor no longer exists
|
||||
const accentColor = generateArcTheme(arc).accent;
|
||||
// Node icon derived from req_type — node.emoji no longer exists
|
||||
const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️";
|
||||
// Progress label derived from req_type — node.requirement.label no longer exists
|
||||
const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
key={node.node_id} // node_id replaces old id
|
||||
className="hc-quest-row"
|
||||
style={
|
||||
{ "--ac": arc.accentColor } as React.CSSProperties
|
||||
}
|
||||
style={{ "--ac": accentColor } as React.CSSProperties}
|
||||
onClick={() => !isClaimable && handleViewAll()}
|
||||
>
|
||||
<div
|
||||
className={`hc-q-icon${isClaimable ? " claimable" : ""}`}
|
||||
>
|
||||
{isClaimable ? "📦" : node.emoji}
|
||||
{isClaimable ? "📦" : nodeEmoji}
|
||||
</div>
|
||||
<div className="hc-q-body">
|
||||
<p className="hc-q-name">{node.title}</p>
|
||||
{/* node.name replaces old node.title */}
|
||||
<p className="hc-q-name">{node.name ?? "—"}</p>
|
||||
{isClaimable ? (
|
||||
<p className="hc-q-claimable">✨ Ready to claim!</p>
|
||||
) : (
|
||||
<p className="hc-q-sub">
|
||||
{node.progress}/{node.requirement.target}{" "}
|
||||
{node.requirement.label} · {pct}%
|
||||
{/* current_value / req_target replace old progress / requirement.target */}
|
||||
{node.current_value}/{node.req_target} {reqLabel}{" "}
|
||||
· {pct}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -804,8 +806,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="hc-map-link" onClick={handleViewAll}>
|
||||
<Map size={13} />
|
||||
View quest map
|
||||
<Map size={13} /> View quest map
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@ -813,7 +814,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
|
||||
</div>
|
||||
|
||||
{claimingNode && (
|
||||
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
|
||||
<ChestOpenModal
|
||||
node={claimingNode.node}
|
||||
claimResult={claimResult}
|
||||
onClose={handleChestClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user