fix(inventory): fix inventory modal instantiation

This commit is contained in:
shafin-r
2026-03-04 01:23:21 +06:00
parent 9074b17a83
commit 79fc2eacdc

View File

@ -1,4 +1,5 @@
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 {
@ -43,7 +44,6 @@ const STYLES = `
to { transform: translateY(0); opacity:1; }
}
/* Sea shimmer bg */
.inv-sheet::before {
content: '';
position: absolute; inset: 0; pointer-events: none; z-index: 0;
@ -58,7 +58,6 @@ const STYLES = `
100% { background-position: 100% 100%, 0% 100%; }
}
/* Gold orb top-right */
.inv-sheet::after {
content: '';
position: absolute; top: -60px; right: -40px; z-index: 0;
@ -67,7 +66,6 @@ const STYLES = `
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;
@ -77,7 +75,6 @@ const STYLES = `
background: rgba(255,255,255,0.1);
}
/* ── Header ── */
.inv-header {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
@ -108,7 +105,6 @@ const STYLES = `
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;
@ -140,7 +136,6 @@ const STYLES = `
margin-left: 0.1rem;
}
/* ── Divider ── */
.inv-divider {
position: relative; z-index: 2;
height: 1px; margin: 0.85rem 1.3rem 0;
@ -154,7 +149,6 @@ const STYLES = `
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;
@ -162,7 +156,6 @@ const STYLES = `
}
.inv-scroll::-webkit-scrollbar { display: none; }
/* ── Empty state ── */
.inv-empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 0.6rem;
@ -173,7 +166,6 @@ const STYLES = `
}
.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;
}
@ -187,12 +179,10 @@ const STYLES = `
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);
@ -213,8 +203,6 @@ const STYLES = `
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);
@ -223,26 +211,17 @@ const STYLES = `
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.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;
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;
@ -258,30 +237,23 @@ const STYLES = `
.inv-card-active-dot {
position: absolute; top: -3px; right: -3px;
width: 10px; height: 10px; border-radius: 50%;
background: #fbbf24;
border: 2px solid #08111f;
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;
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;
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;
@ -299,11 +271,8 @@ const STYLES = `
letter-spacing: 0.1em; text-transform: uppercase;
color: rgba(255,255,255,0.22);
}
/* Activate button */
.inv-activate-btn {
width: 100%;
padding: 0.48rem;
width: 100%; padding: 0.48rem;
border-radius: 10px; border: none; cursor: pointer;
font-family: 'Nunito', sans-serif;
font-size: 0.7rem; font-weight: 900;
@ -315,10 +284,7 @@ const STYLES = `
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.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);
@ -330,8 +296,7 @@ const STYLES = `
.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;
color: #fbbf24; cursor: default;
}
.inv-activate-btn.success-flash {
background: rgba(74,222,128,0.18);
@ -339,24 +304,17 @@ const STYLES = `
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); }
}
@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);
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;
z-index: 9999;
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 1.2rem;
background: linear-gradient(135deg, #1a3a1a, #0d2010);
@ -380,13 +338,11 @@ const ITEM_ICON: Record<string, string> = {
title: "🏴‍☠️",
coin_boost: "🪙",
};
const ITEM_ICON_DEFAULT = "📦";
function itemIcon(effectType: string): string {
return ITEM_ICON[effectType] ?? ITEM_ICON_DEFAULT;
return ITEM_ICON[effectType] ?? "📦";
}
// ─── Check if an item is currently active ─────────────────────────────────────
function isItemActive(
item: InventoryItem,
activeEffects: ActiveEffect[],
@ -438,33 +394,23 @@ const ItemCard = ({
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)}
@ -504,11 +450,9 @@ export const InventoryModal = ({ onClose }: Props) => {
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 {
@ -520,31 +464,24 @@ export const InventoryModal = ({ onClose }: Props) => {
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);
@ -560,7 +497,6 @@ export const InventoryModal = ({ onClose }: Props) => {
[token, items],
);
// Cleanup timer on unmount
useEffect(
() => () => {
if (toastTimer.current) clearTimeout(toastTimer.current);
@ -570,18 +506,19 @@ export const InventoryModal = ({ onClose }: Props) => {
const liveEffects = getLiveEffects(activeEffects);
return (
// 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()}>
{/* 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>
@ -592,7 +529,6 @@ export const InventoryModal = ({ onClose }: Props) => {
</button>
</div>
{/* Active effects bar */}
{liveEffects.length > 0 && (
<div className="inv-active-bar">
{liveEffects.map((e) => (
@ -616,7 +552,6 @@ export const InventoryModal = ({ onClose }: Props) => {
: "Your hold"}
</p>
{/* Scroll area */}
<div className="inv-scroll">
{loading && items.length === 0 ? (
<div className="inv-skeleton-grid">
@ -649,7 +584,6 @@ export const InventoryModal = ({ onClose }: Props) => {
</div>
)}
{/* Error inline */}
{error && (
<p
style={{
@ -668,8 +602,8 @@ export const InventoryModal = ({ onClose }: Props) => {
</div>
</div>
{/* Success toast */}
{showToast && <div className="inv-toast">{toastMsg}</div>}
</>
</>,
document.body,
);
};