fix(api): fix api integration for quest map and adjacent components
This commit is contained in:
675
src/components/InventoryModal.tsx
Normal file
675
src/components/InventoryModal.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user