Files
edbridge-scholars/src/components/InventoryModal.tsx

616 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);
};