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 = { 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 (
{/* Icon */}
{itemIcon(inv.item.effect_type)} {isActive &&
}
{/* Name + description */}

{inv.item.name}

{inv.item.description}

{/* Qty + type */}
×{inv.quantity} {inv.item.type.replace(/_/g, " ")}
{/* Time remaining if active */} {isActive && activeEffect && (
{formatTimeLeft(activeEffect.expires_at)} remaining
)} {/* Activate button */}
); }; // ─── 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 | 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 ( <>
e.stopPropagation()}> {/* Handle */}
{/* Header */}
⚓ Pirate's Hold

Inventory

{/* Active effects bar */} {liveEffects.length > 0 && (
{liveEffects.map((e) => (
{itemIcon(e.item.effect_type)} {e.item.name} {formatTimeLeft(e.expires_at)}
))}
)}

{items.length > 0 ? `${items.length} item${items.length !== 1 ? "s" : ""} in your hold` : "Your hold"}

{/* Scroll area */}
{loading && items.length === 0 ? (
{[0, 1, 2, 3].map((i) => (
))}
) : items.length === 0 ? (
🏴‍☠️

Your hold is empty — claim quests to earn items!

) : (
{items.map((inv, i) => ( ))}
)} {/* Error inline */} {error && (

⚠️ {error}

)}
{/* Success toast */} {showToast &&
{toastMsg}
} ); };