121 lines
3.7 KiB
TypeScript
121 lines
3.7 KiB
TypeScript
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`;
|
|
}
|