616 lines
21 KiB
TypeScript
616 lines
21 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from "react";
|
||
import { createPortal } from "react-dom";
|
||
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;
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.inv-sheet {
|
||
max-width: 1000px;
|
||
}
|
||
}
|
||
@keyframes invSlideUp {
|
||
from { transform: translateY(100%); opacity:0; }
|
||
to { transform: translateY(0); opacity:1; }
|
||
}
|
||
|
||
.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%; }
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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; }
|
||
|
||
.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; }
|
||
|
||
.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; }
|
||
}
|
||
|
||
.inv-grid {
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
|
||
}
|
||
|
||
.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); }
|
||
.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);
|
||
}
|
||
@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; }
|
||
.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%); }
|
||
.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); }
|
||
}
|
||
.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;
|
||
}
|
||
.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);
|
||
}
|
||
.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; }
|
||
.inv-active-time {
|
||
font-family: 'Nunito Sans', sans-serif;
|
||
font-size: 0.55rem; font-weight: 700; color: rgba(251,191,36,0.5);
|
||
}
|
||
|
||
.inv-toast {
|
||
position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom));
|
||
left: 50%; transform: translateX(-50%);
|
||
z-index: 9999;
|
||
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: "🪙",
|
||
};
|
||
|
||
function itemIcon(effectType: string): string {
|
||
return ITEM_ICON[effectType] ?? "📦";
|
||
}
|
||
|
||
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" />
|
||
<div className="inv-card-icon-wrap">
|
||
{itemIcon(inv.item.effect_type)}
|
||
{isActive && <div className="inv-card-active-dot" />}
|
||
</div>
|
||
<p className="inv-card-name">{inv.item.name}</p>
|
||
<p className="inv-card-desc">{inv.item.description}</p>
|
||
<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>
|
||
{isActive && activeEffect && (
|
||
<div className="inv-active-time">
|
||
{formatTimeLeft(activeEffect.expires_at)} remaining
|
||
</div>
|
||
)}
|
||
<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);
|
||
|
||
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]);
|
||
|
||
const handleActivate = useCallback(
|
||
async (itemId: string) => {
|
||
if (!token) return;
|
||
activateItemOptimistic(itemId);
|
||
try {
|
||
const updatedInv = await api.activateItem(token, itemId);
|
||
activateItemSuccess(updatedInv, itemId);
|
||
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);
|
||
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],
|
||
);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const liveEffects = getLiveEffects(activeEffects);
|
||
|
||
// Portal the entire modal to document.body so it always
|
||
// renders at the top of the DOM tree, escaping any parent
|
||
// stacking context, overflow:hidden, or z-index constraints.
|
||
return createPortal(
|
||
<>
|
||
<style>{STYLES}</style>
|
||
|
||
<div className="inv-overlay" onClick={onClose}>
|
||
<div className="inv-sheet" onClick={(e) => e.stopPropagation()}>
|
||
<div className="inv-handle-row">
|
||
<div className="inv-handle" />
|
||
</div>
|
||
|
||
<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>
|
||
|
||
{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>
|
||
|
||
<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 && (
|
||
<p
|
||
style={{
|
||
textAlign: "center",
|
||
padding: "0.5rem",
|
||
fontFamily: "'Nunito',sans-serif",
|
||
fontSize: "0.72rem",
|
||
color: "#ef4444",
|
||
fontWeight: 800,
|
||
}}
|
||
>
|
||
⚠️ {error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{showToast && <div className="inv-toast">{toastMsg}</div>}
|
||
</>,
|
||
document.body,
|
||
);
|
||
};
|