fix(api): fix api integration for quest map and adjacent components

This commit is contained in:
shafin-r
2026-03-01 12:57:54 +06:00
parent c7f0183956
commit 2eaf77e13c
12 changed files with 2039 additions and 618 deletions

View File

@ -0,0 +1,120 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type {
InventoryItem,
ActiveEffect,
UserInventory,
} from "../types/quest";
// ─── Store interface ──────────────────────────────────────────────────────────
interface InventoryStore {
// Raw inventory from API
items: InventoryItem[];
activeEffects: ActiveEffect[];
// Loading / error
loading: boolean;
activatingId: string | null; // item id currently being activated
error: string | null;
lastActivatedId: string | null; // shows success state briefly
// Actions
syncFromAPI: (inv: UserInventory) => void;
activateItemOptimistic: (itemId: string) => void;
activateItemSuccess: (inv: UserInventory, itemId: string) => void;
activateItemError: (itemId: string, error: string) => void;
clearLastActivated: () => void;
setLoading: (v: boolean) => void;
}
export const useInventoryStore = create<InventoryStore>()(
persist(
(set) => ({
items: [],
activeEffects: [],
loading: false,
activatingId: null,
error: null,
lastActivatedId: null,
syncFromAPI: (inv) =>
set({ items: inv.items, activeEffects: inv.active_effects }),
// Optimistic — mark as "activating" immediately for instant UI feedback
activateItemOptimistic: (itemId) =>
set({ activatingId: itemId, error: null }),
// On API success — replace inventory with fresh server state
activateItemSuccess: (inv, itemId) =>
set({
items: inv.items,
activeEffects: inv.active_effects,
activatingId: null,
lastActivatedId: itemId,
}),
activateItemError: (itemId, error) => set({ activatingId: null, error }),
clearLastActivated: () => set({ lastActivatedId: null }),
setLoading: (v) => set({ loading: v }),
}),
{
name: "inventory-store",
storage: createJSONStorage(() => localStorage),
// Persist items + active effects so the app can show active item banners
// without waiting for a network request on every mount
partialize: (state) => ({
items: state.items,
activeEffects: state.activeEffects,
}),
},
),
);
// ─── Selector helpers (call these in components, not inside the store) ─────────
/** Returns true if any active effect has the given effect_type */
export function hasActiveEffect(
activeEffects: ActiveEffect[],
effectType: string,
): boolean {
const now = Date.now();
return activeEffects.some(
(e) =>
e.item.effect_type === effectType &&
new Date(e.expires_at).getTime() > now,
);
}
/** Returns the active effect for a given effect_type, or null */
export function getActiveEffect(
activeEffects: ActiveEffect[],
effectType: string,
): ActiveEffect | null {
const now = Date.now();
return (
activeEffects.find(
(e) =>
e.item.effect_type === effectType &&
new Date(e.expires_at).getTime() > now,
) ?? null
);
}
/** Returns all non-expired active effects */
export function getLiveEffects(activeEffects: ActiveEffect[]): ActiveEffect[] {
const now = Date.now();
return activeEffects.filter((e) => new Date(e.expires_at).getTime() > now);
}
/** Formats time remaining as "2h 14m" or "43m" */
export function formatTimeLeft(expiresAt: string): string {
const msLeft = new Date(expiresAt).getTime() - Date.now();
if (msLeft <= 0) return "Expired";
const totalMin = Math.floor(msLeft / 60_000);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}

View File

@ -1,6 +1,6 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type { QuestArc, QuestNode, NodeStatus } from "../types/quest";
import type { QuestArc, QuestNode } from "../types/quest";
import { CREW_RANKS } from "../types/quest";
import { QUEST_ARCS } from "../data/questData";
@ -21,24 +21,37 @@ export interface QuestSummary {
activeNodes: number;
claimableNodes: number;
lockedNodes: number;
totalXP: number;
earnedXP: number;
// totalXP removed — node definitions no longer carry an XP value.
// Awarded XP only comes back from ClaimedRewardResponse at claim time.
earnedXP: number; // accumulated from claim responses, stored in state
arcsCompleted: number;
totalArcs: number;
earnedTitles: string[];
earnedTitles: string[]; // accumulated from claim responses, stored in state
crewRank: CrewRank;
}
// ─── Store — ONLY raw state + actions, never derived values ───────────────────
// Storing functions that return new objects/arrays in Zustand causes infinite
// re-render loops because Zustand uses Object.is to detect changes.
// All derived values live below as plain helper functions instead.
// ─── Store ────────────────────────────────────────────────────────────────────
interface QuestStore {
arcs: QuestArc[];
activeArcId: string;
// XP and titles are no longer derivable from node fields alone —
// they come from ClaimedRewardResponse at claim time, so we track them here.
earnedXP: number;
earnedTitles: string[];
setActiveArc: (arcId: string) => void;
claimNode: (arcId: string, nodeId: string) => void;
/**
* Call this after a successful /journey/claim API response.
* Pass the xp and titles returned by ClaimedRewardResponse so the store
* stays in sync without needing to re-fetch the whole journey.
*/
claimNode: (
arcId: string,
nodeId: string,
xpAwarded?: number,
titlesAwarded?: string[],
) => void;
syncFromAPI: (arcs: QuestArc[]) => void;
}
@ -47,22 +60,29 @@ export const useQuestStore = create<QuestStore>()(
(set) => ({
arcs: QUEST_ARCS,
activeArcId: QUEST_ARCS[0].id,
earnedXP: 0,
earnedTitles: [],
setActiveArc: (arcId) => set({ activeArcId: arcId }),
claimNode: (arcId, nodeId) =>
claimNode: (arcId, nodeId, xpAwarded = 0, titlesAwarded = []) =>
set((state) => ({
// Accumulate XP and titles from the claim response
earnedXP: state.earnedXP + xpAwarded,
earnedTitles: [...state.earnedTitles, ...titlesAwarded],
arcs: state.arcs.map((arc) => {
if (arc.id !== arcId) return arc;
const nodeIdx = arc.nodes.findIndex((n) => n.id === nodeId);
// node_id is the new primary key — replaces old n.id
const nodeIdx = arc.nodes.findIndex((n) => n.node_id === nodeId);
if (nodeIdx === -1) return arc;
return {
...arc,
nodes: arc.nodes.map((n, i) => {
if (n.id === nodeId)
return { ...n, status: "completed" as NodeStatus };
if (n.node_id === nodeId) return { ...n, status: "completed" };
// Unlock the next locked node in sequence
if (i === nodeIdx + 1 && n.status === "locked")
return { ...n, status: "active" as NodeStatus };
return { ...n, status: "active" };
return n;
}),
};
@ -77,34 +97,21 @@ export const useQuestStore = create<QuestStore>()(
partialize: (state) => ({
arcs: state.arcs,
activeArcId: state.activeArcId,
earnedXP: state.earnedXP,
earnedTitles: state.earnedTitles,
}),
},
),
);
// ─── Standalone helper functions ──────────────────────────────────────────────
// Call these in your components AFTER selecting arcs from the store.
// Because they take arcs as an argument (not selected from the store),
// they never cause re-render loops.
//
// Usage:
// const arcs = useQuestStore(s => s.arcs);
// const summary = getQuestSummary(arcs);
// const rank = getCrewRank(arcs);
export function getEarnedXP(arcs: QuestArc[]): number {
return arcs
.flatMap((a) => a.nodes)
.filter((n) => n.status === "completed")
.reduce((sum, n) => sum + n.reward.xp, 0);
}
export function getCrewRank(arcs: QuestArc[]): CrewRank {
const xp = getEarnedXP(arcs);
export function getCrewRank(earnedXP: number): CrewRank {
// Accepts earnedXP directly — no longer iterates nodes (reward.xp is gone)
const ladder = [...CREW_RANKS];
let idx = 0;
for (let i = ladder.length - 1; i >= 0; i--) {
if (xp >= ladder[i].xpRequired) {
if (earnedXP >= ladder[i].xpRequired) {
idx = i;
break;
}
@ -116,7 +123,7 @@ export function getCrewRank(arcs: QuestArc[]): CrewRank {
progressToNext: nextRank
? Math.min(
1,
(xp - current.xpRequired) /
(earnedXP - current.xpRequired) /
(nextRank.xpRequired - current.xpRequired),
)
: 1,
@ -126,25 +133,25 @@ export function getCrewRank(arcs: QuestArc[]): CrewRank {
};
}
export function getQuestSummary(arcs: QuestArc[]): QuestSummary {
export function getQuestSummary(
arcs: QuestArc[],
earnedXP: number,
earnedTitles: string[],
): QuestSummary {
const allNodes = arcs.flatMap((a) => a.nodes);
const earnedXP = getEarnedXP(arcs);
return {
totalNodes: allNodes.length,
completedNodes: allNodes.filter((n) => n.status === "completed").length,
activeNodes: allNodes.filter((n) => n.status === "active").length,
claimableNodes: allNodes.filter((n) => n.status === "claimable").length,
lockedNodes: allNodes.filter((n) => n.status === "locked").length,
totalXP: allNodes.reduce((s, n) => s + n.reward.xp, 0),
earnedXP,
arcsCompleted: arcs.filter((a) =>
a.nodes.every((n) => n.status === "completed"),
).length,
totalArcs: arcs.length,
earnedTitles: allNodes
.filter((n) => n.status === "completed" && n.reward.title)
.map((n) => n.reward.title!),
crewRank: getCrewRank(arcs),
earnedTitles,
crewRank: getCrewRank(earnedXP),
};
}
@ -153,11 +160,12 @@ export function getClaimableCount(arcs: QuestArc[]): number {
.length;
}
// node_id is the new primary key — replaces old n.id
export function getNode(
arcs: QuestArc[],
nodeId: string,
): QuestNode | undefined {
return arcs.flatMap((a) => a.nodes).find((n) => n.id === nodeId);
return arcs.flatMap((a) => a.nodes).find((n) => n.node_id === nodeId);
}
export function getActiveArc(arcs: QuestArc[], activeArcId: string): QuestArc {