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,675 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { X } from "lucide-react";
import type { InventoryItem, ActiveEffect } from "../types/quest";
import {
useInventoryStore,
getLiveEffects,
formatTimeLeft,
} from "../stores/useInventoryStore";
import { useAuthStore } from "../stores/authStore";
import { api } from "../utils/api";
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* ══ OVERLAY ══ */
.inv-overlay {
position: fixed; inset: 0; z-index: 60;
background: rgba(2,5,15,0.78);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
display: flex; align-items: flex-end; justify-content: center;
animation: invFadeIn 0.2s ease both;
}
@keyframes invFadeIn { from{opacity:0} to{opacity:1} }
/* ══ SHEET ══ */
.inv-sheet {
width: 100%; max-width: 540px;
background: linear-gradient(180deg, #08111f 0%, #050d1a 100%);
border-radius: 28px 28px 0 0;
border-top: 1.5px solid rgba(251,191,36,0.25);
box-shadow:
0 -16px 60px rgba(0,0,0,0.7),
inset 0 1px 0 rgba(255,255,255,0.06);
overflow: hidden;
display: flex; flex-direction: column;
max-height: 88vh;
animation: invSlideUp 0.38s cubic-bezier(0.34,1.56,0.64,1) both;
position: relative;
}
@keyframes invSlideUp {
from { transform: translateY(100%); opacity:0; }
to { transform: translateY(0); opacity:1; }
}
/* Sea shimmer bg */
.inv-sheet::before {
content: '';
position: absolute; inset: 0; pointer-events: none; z-index: 0;
background:
repeating-linear-gradient(110deg, transparent 60%, rgba(56,189,248,0.015) 61%, transparent 62%),
repeating-linear-gradient(70deg, transparent 72%, rgba(56,189,248,0.01) 73%, transparent 74%);
background-size: 300% 300%, 240% 240%;
animation: invSeaSway 16s ease-in-out infinite alternate;
}
@keyframes invSeaSway {
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
/* Gold orb top-right */
.inv-sheet::after {
content: '';
position: absolute; top: -60px; right: -40px; z-index: 0;
width: 220px; height: 220px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.07), transparent 68%);
pointer-events: none;
}
/* ── Handle ── */
.inv-handle-row {
display: flex; justify-content: center;
padding: 0.75rem 0 0; flex-shrink: 0; position: relative; z-index: 2;
}
.inv-handle {
width: 40px; height: 4px; border-radius: 100px;
background: rgba(255,255,255,0.1);
}
/* ── Header ── */
.inv-header {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
padding: 0.85rem 1.3rem 0;
}
.inv-header-left { display: flex; flex-direction: column; gap: 0.1rem; }
.inv-eyebrow {
font-family: 'Cinzel', serif;
font-size: 0.5rem; font-weight: 700; letter-spacing: 0.22em;
text-transform: uppercase; color: rgba(251,191,36,0.55);
}
.inv-title {
font-family: 'Cinzel', serif;
font-size: 1.28rem; font-weight: 900; color: #fff;
letter-spacing: 0.03em;
text-shadow: 0 0 24px rgba(251,191,36,0.3);
}
.inv-close {
width: 32px; height: 32px; border-radius: 50%;
border: 1.5px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.15s;
flex-shrink: 0;
}
.inv-close:hover {
border-color: rgba(251,191,36,0.5);
background: rgba(251,191,36,0.1);
}
/* ── Active effects banner ── */
.inv-active-bar {
position: relative; z-index: 2;
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: none;
padding: 0.75rem 1.3rem 0;
}
.inv-active-bar::-webkit-scrollbar { display: none; }
.inv-active-pill {
display: flex; align-items: center; gap: 0.4rem;
flex-shrink: 0;
padding: 0.35rem 0.75rem;
border-radius: 100px;
border: 1.5px solid rgba(251,191,36,0.35);
background: rgba(251,191,36,0.08);
animation: invPillGlow 2.4s ease-in-out infinite;
}
@keyframes invPillGlow {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 12px 2px rgba(251,191,36,0.18); }
}
.inv-active-pill-icon { font-size: 0.9rem; }
.inv-active-pill-name {
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 900; color: #fbbf24;
}
.inv-active-pill-time {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.6rem; font-weight: 700;
color: rgba(251,191,36,0.5);
margin-left: 0.1rem;
}
/* ── Divider ── */
.inv-divider {
position: relative; z-index: 2;
height: 1px; margin: 0.85rem 1.3rem 0;
background: rgba(255,255,255,0.06);
}
.inv-section-label {
position: relative; z-index: 2;
padding: 0.7rem 1.3rem 0.35rem;
font-family: 'Cinzel', serif;
font-size: 0.48rem; font-weight: 700; letter-spacing: 0.2em;
text-transform: uppercase; color: rgba(255,255,255,0.25);
}
/* ── Scrollable item grid ── */
.inv-scroll {
position: relative; z-index: 2;
flex: 1; overflow-y: auto; scrollbar-width: none;
padding: 0 1.1rem calc(1.5rem + env(safe-area-inset-bottom));
}
.inv-scroll::-webkit-scrollbar { display: none; }
/* ── Empty state ── */
.inv-empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 0.6rem;
padding: 3rem 1rem;
font-family: 'Nunito', sans-serif;
font-size: 0.85rem; font-weight: 800;
color: rgba(255,255,255,0.25);
}
.inv-empty-icon { font-size: 2.5rem; opacity: 0.4; }
/* ── Loading skeleton ── */
.inv-skeleton-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
}
.inv-skeleton-card {
height: 140px; border-radius: 20px;
background: rgba(255,255,255,0.04);
animation: invSkel 1.6s ease-in-out infinite;
}
@keyframes invSkel {
0%,100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* ── Item grid ── */
.inv-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
}
/* ── Item card ── */
.inv-card {
border-radius: 20px; padding: 1rem;
border: 1.5px solid rgba(255,255,255,0.07);
background: rgba(255,255,255,0.03);
display: flex; flex-direction: column; gap: 0.6rem;
cursor: pointer; position: relative; overflow: hidden;
transition: border-color 0.2s, background 0.2s, transform 0.15s;
animation: invCardIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
animation-delay: var(--ci-delay, 0s);
}
@keyframes invCardIn {
from { opacity:0; transform: translateY(14px) scale(0.95); }
to { opacity:1; transform: translateY(0) scale(1); }
}
.inv-card:hover {
border-color: rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
transform: translateY(-2px);
}
.inv-card:active { transform: translateY(0) scale(0.98); }
/* Active card styling */
.inv-card.is-active {
border-color: rgba(251,191,36,0.4);
background: rgba(251,191,36,0.06);
}
.inv-card.is-active:hover {
border-color: rgba(251,191,36,0.6);
background: rgba(251,191,36,0.09);
}
/* Just-activated flash */
@keyframes invActivateFlash {
0% { background: rgba(251,191,36,0.25); border-color: rgba(251,191,36,0.8); }
100%{ background: rgba(251,191,36,0.06); border-color: rgba(251,191,36,0.4); }
}
.inv-card.just-activated {
animation: invActivateFlash 0.9s ease forwards;
}
/* Card shimmer overlay */
.inv-card-sheen {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.04) 50%, transparent 70%);
transform: translateX(-100%);
transition: transform 0.5s ease;
}
.inv-card:hover .inv-card-sheen { transform: translateX(100%); }
/* Icon area */
.inv-card-icon-wrap {
width: 44px; height: 44px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0; position: relative;
}
.inv-card.is-active .inv-card-icon-wrap {
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.3);
}
.inv-card-active-dot {
position: absolute; top: -3px; right: -3px;
width: 10px; height: 10px; border-radius: 50%;
background: #fbbf24;
border: 2px solid #08111f;
animation: invDotPulse 2s ease-in-out infinite;
}
@keyframes invDotPulse {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0.6); }
50% { box-shadow: 0 0 0 5px rgba(251,191,36,0); }
}
/* Card text */
.inv-card-name {
font-family: 'Nunito', sans-serif;
font-size: 0.82rem; font-weight: 900; color: #fff;
line-height: 1.2;
}
.inv-card.is-active .inv-card-name { color: #fbbf24; }
.inv-card-desc {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.63rem; font-weight: 600;
color: rgba(255,255,255,0.38); line-height: 1.4;
flex: 1;
}
/* Qty + type row */
.inv-card-meta {
display: flex; align-items: center; justify-content: space-between;
gap: 0.4rem; margin-top: auto;
}
.inv-card-qty {
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900;
color: rgba(255,255,255,0.3);
background: rgba(255,255,255,0.05);
border-radius: 100px; padding: 0.15rem 0.45rem;
}
.inv-card-type {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.56rem; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase;
color: rgba(255,255,255,0.22);
}
/* Activate button */
.inv-activate-btn {
width: 100%;
padding: 0.48rem;
border-radius: 10px; border: none; cursor: pointer;
font-family: 'Nunito', sans-serif;
font-size: 0.7rem; font-weight: 900;
transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
}
.inv-activate-btn.idle {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.6);
}
.inv-activate-btn.idle:hover {
background: rgba(255,255,255,0.12);
color: white;
}
.inv-activate-btn.activating {
background: rgba(251,191,36,0.1);
border: 1px solid rgba(251,191,36,0.25);
color: rgba(251,191,36,0.6);
cursor: not-allowed;
animation: invSpinLabel 0.4s ease infinite alternate;
}
@keyframes invSpinLabel { from{opacity:0.5} to{opacity:1} }
.inv-activate-btn.active-state {
background: rgba(251,191,36,0.12);
border: 1px solid rgba(251,191,36,0.3);
color: #fbbf24;
cursor: default;
}
.inv-activate-btn.success-flash {
background: rgba(74,222,128,0.18);
border: 1px solid rgba(74,222,128,0.4);
color: #4ade80;
animation: invSuccessScale 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes invSuccessScale {
from { transform: scale(0.94); }
to { transform: scale(1); }
}
.inv-activate-btn:disabled { pointer-events: none; }
/* Time remaining on active button */
.inv-active-time {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.55rem; font-weight: 700;
color: rgba(251,191,36,0.5);
}
/* ── Toast ── */
.inv-toast {
position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom));
left: 50%; transform: translateX(-50%);
z-index: 90;
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 1.2rem;
background: linear-gradient(135deg, #1a3a1a, #0d2010);
border: 1.5px solid rgba(74,222,128,0.45);
border-radius: 100px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 20px rgba(74,222,128,0.12);
font-family: 'Nunito', sans-serif;
font-size: 0.8rem; font-weight: 900; color: #4ade80;
white-space: nowrap;
animation: invToastIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both,
invToastOut 0.3s 2.7s ease forwards;
}
@keyframes invToastIn { from{opacity:0; transform:translateX(-50%) translateY(20px) scale(0.9)} to{opacity:1; transform:translateX(-50%) translateY(0) scale(1)} }
@keyframes invToastOut { from{opacity:1} to{opacity:0; transform:translateX(-50%) translateY(8px)} }
`;
// ─── Item metadata ─────────────────────────────────────────────────────────────
const ITEM_ICON: Record<string, string> = {
xp_boost: "⚡",
streak_shield: "🛡️",
title: "🏴‍☠️",
coin_boost: "🪙",
};
const ITEM_ICON_DEFAULT = "📦";
function itemIcon(effectType: string): string {
return ITEM_ICON[effectType] ?? ITEM_ICON_DEFAULT;
}
// ─── Check if an item is currently active ─────────────────────────────────────
function isItemActive(
item: InventoryItem,
activeEffects: ActiveEffect[],
): ActiveEffect | null {
const now = Date.now();
return (
activeEffects.find(
(e) =>
e.item.id === item.item.id && new Date(e.expires_at).getTime() > now,
) ?? null
);
}
// ─── Item card ────────────────────────────────────────────────────────────────
const ItemCard = ({
inv,
activeEffects,
activatingId,
lastActivatedId,
onActivate,
index,
}: {
inv: InventoryItem;
activeEffects: ActiveEffect[];
activatingId: string | null;
lastActivatedId: string | null;
onActivate: (id: string) => void;
index: number;
}) => {
const activeEffect = isItemActive(inv, activeEffects);
const isActive = !!activeEffect;
const isActivating = activatingId === inv.id;
const justActivated = lastActivatedId === inv.id;
let btnState: "idle" | "activating" | "active-state" | "success-flash" =
"idle";
if (justActivated) btnState = "success-flash";
else if (isActivating) btnState = "activating";
else if (isActive) btnState = "active-state";
let btnLabel = "Use Item";
if (btnState === "activating") btnLabel = "Activating…";
else if (btnState === "success-flash") btnLabel = "✓ Activated!";
else if (btnState === "active-state") btnLabel = "✓ Active";
return (
<div
className={`inv-card${isActive ? " is-active" : ""}${justActivated ? " just-activated" : ""}`}
style={{ "--ci-delay": `${index * 0.045}s` } as React.CSSProperties}
>
<div className="inv-card-sheen" />
{/* Icon */}
<div className="inv-card-icon-wrap">
{itemIcon(inv.item.effect_type)}
{isActive && <div className="inv-card-active-dot" />}
</div>
{/* Name + description */}
<p className="inv-card-name">{inv.item.name}</p>
<p className="inv-card-desc">{inv.item.description}</p>
{/* Qty + type */}
<div className="inv-card-meta">
<span className="inv-card-qty">×{inv.quantity}</span>
<span className="inv-card-type">
{inv.item.type.replace(/_/g, " ")}
</span>
</div>
{/* Time remaining if active */}
{isActive && activeEffect && (
<div className="inv-active-time">
{formatTimeLeft(activeEffect.expires_at)} remaining
</div>
)}
{/* Activate button */}
<button
className={`inv-activate-btn ${btnState}`}
onClick={() => !isActive && !isActivating && onActivate(inv.id)}
disabled={isActive || isActivating}
>
{btnLabel}
</button>
</div>
);
};
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
onClose: () => void;
}
export const InventoryModal = ({ onClose }: Props) => {
const token = useAuthStore((s) => s.token);
const items = useInventoryStore((s) => s.items);
const activeEffects = useInventoryStore((s) => s.activeEffects);
const loading = useInventoryStore((s) => s.loading);
const activatingId = useInventoryStore((s) => s.activatingId);
const lastActivatedId = useInventoryStore((s) => s.lastActivatedId);
const error = useInventoryStore((s) => s.error);
const syncFromAPI = useInventoryStore((s) => s.syncFromAPI);
const setLoading = useInventoryStore((s) => s.setLoading);
const activateItemOptimistic = useInventoryStore(
(s) => s.activateItemOptimistic,
);
const activateItemSuccess = useInventoryStore((s) => s.activateItemSuccess);
const activateItemError = useInventoryStore((s) => s.activateItemError);
const clearLastActivated = useInventoryStore((s) => s.clearLastActivated);
const [showToast, setShowToast] = useState(false);
const [toastMsg, setToastMsg] = useState("");
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Fetch on open ──────────────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
let cancelled = false;
const fetchInv = async () => {
setLoading(true);
try {
const inv = await api.fetchUserInventory(token);
if (!cancelled) syncFromAPI(inv);
} catch (e) {
// Silently fail — cached data stays visible
} finally {
if (!cancelled) setLoading(false);
}
};
fetchInv();
return () => {
cancelled = true;
};
}, [token]);
// ── Activate ──────────────────────────────────────────────────────────────
const handleActivate = useCallback(
async (itemId: string) => {
if (!token) return;
activateItemOptimistic(itemId);
try {
const updatedInv = await api.activateItem(token, itemId);
activateItemSuccess(updatedInv, itemId);
// Find item name for toast
const name = items.find((i) => i.id === itemId)?.item.name ?? "Item";
setToastMsg(
`${itemIcon(items.find((i) => i.id === itemId)?.item.effect_type ?? "")} ${name} activated!`,
);
setShowToast(true);
// Auto-clear success state + toast
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => {
setShowToast(false);
clearLastActivated();
}, 3000);
} catch (e) {
activateItemError(
itemId,
e instanceof Error ? e.message : "Failed to activate",
);
}
},
[token, items],
);
// Cleanup timer on unmount
useEffect(
() => () => {
if (toastTimer.current) clearTimeout(toastTimer.current);
},
[],
);
const liveEffects = getLiveEffects(activeEffects);
return (
<>
<style>{STYLES}</style>
<div className="inv-overlay" onClick={onClose}>
<div className="inv-sheet" onClick={(e) => e.stopPropagation()}>
{/* Handle */}
<div className="inv-handle-row">
<div className="inv-handle" />
</div>
{/* Header */}
<div className="inv-header">
<div className="inv-header-left">
<span className="inv-eyebrow"> Pirate's Hold</span>
<h2 className="inv-title">Inventory</h2>
</div>
<button className="inv-close" onClick={onClose}>
<X size={14} color="rgba(255,255,255,0.5)" />
</button>
</div>
{/* Active effects bar */}
{liveEffects.length > 0 && (
<div className="inv-active-bar">
{liveEffects.map((e) => (
<div key={e.id} className="inv-active-pill">
<span className="inv-active-pill-icon">
{itemIcon(e.item.effect_type)}
</span>
<span className="inv-active-pill-name">{e.item.name}</span>
<span className="inv-active-pill-time">
{formatTimeLeft(e.expires_at)}
</span>
</div>
))}
</div>
)}
<div className="inv-divider" />
<p className="inv-section-label">
{items.length > 0
? `${items.length} item${items.length !== 1 ? "s" : ""} in your hold`
: "Your hold"}
</p>
{/* Scroll area */}
<div className="inv-scroll">
{loading && items.length === 0 ? (
<div className="inv-skeleton-grid">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="inv-skeleton-card"
style={{ animationDelay: `${i * 0.1}s` }}
/>
))}
</div>
) : items.length === 0 ? (
<div className="inv-empty">
<span className="inv-empty-icon">🏴‍☠️</span>
<p>Your hold is empty — claim quests to earn items!</p>
</div>
) : (
<div className="inv-grid">
{items.map((inv, i) => (
<ItemCard
key={inv.id}
inv={inv}
activeEffects={activeEffects}
activatingId={activatingId}
lastActivatedId={lastActivatedId}
onActivate={handleActivate}
index={i}
/>
))}
</div>
)}
{/* Error inline */}
{error && (
<p
style={{
textAlign: "center",
padding: "0.5rem",
fontFamily: "'Nunito',sans-serif",
fontSize: "0.72rem",
color: "#ef4444",
fontWeight: 800,
}}
>
⚠️ {error}
</p>
)}
</div>
</div>
</div>
{/* Success toast */}
{showToast && <div className="inv-toast">{toastMsg}</div>}
</>
);
};