218 lines
6.8 KiB
TypeScript
218 lines
6.8 KiB
TypeScript
import { useState } from "react";
|
||
import {
|
||
useInventoryStore,
|
||
getLiveEffects,
|
||
formatTimeLeft,
|
||
hasActiveEffect,
|
||
} from "../stores/useInventoryStore";
|
||
import { InventoryModal } from "./InventoryModal";
|
||
|
||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||
const BTN_STYLES = `
|
||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&family=Cinzel:wght@700&display=swap');
|
||
|
||
/* ── Inventory trigger button ── */
|
||
.inv-btn {
|
||
position: relative;
|
||
display: inline-flex; align-items: center; gap: 0.38rem;
|
||
padding: 0.48rem 0.85rem;
|
||
background: rgba(255,255,255,0.05);
|
||
border: 1.5px solid rgba(255,255,255,0.1);
|
||
border-radius: 100px;
|
||
cursor: pointer;
|
||
font-family: 'Nunito', sans-serif;
|
||
font-size: 0.72rem; font-weight: 900;
|
||
color: rgba(255,255,255,0.7);
|
||
transition: all 0.18s ease;
|
||
outline: none;
|
||
white-space: nowrap;
|
||
}
|
||
.inv-btn:hover {
|
||
background: rgba(255,255,255,0.09);
|
||
border-color: rgba(255,255,255,0.2);
|
||
color: white;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||
}
|
||
.inv-btn:active { transform: translateY(0) scale(0.97); }
|
||
|
||
/* When active effects are running — gold glow */
|
||
.inv-btn.has-active {
|
||
border-color: rgba(251,191,36,0.45);
|
||
color: #fbbf24;
|
||
background: rgba(251,191,36,0.08);
|
||
animation: invBtnGlow 2.6s ease-in-out infinite;
|
||
}
|
||
@keyframes invBtnGlow {
|
||
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
|
||
50% { box-shadow: 0 0 14px 3px rgba(251,191,36,0.2); }
|
||
}
|
||
.inv-btn.has-active:hover {
|
||
border-color: rgba(251,191,36,0.7);
|
||
background: rgba(251,191,36,0.14);
|
||
}
|
||
|
||
/* Badge dot */
|
||
.inv-btn-badge {
|
||
position: absolute; top: -4px; right: -4px;
|
||
width: 14px; height: 14px; border-radius: 50%;
|
||
background: #fbbf24;
|
||
border: 2px solid transparent; /* will be set to match parent bg via CSS var */
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-family: 'Nunito', sans-serif;
|
||
font-size: 0.45rem; font-weight: 900; color: #1a0800;
|
||
animation: invBadgePop 1.8s ease-in-out infinite;
|
||
}
|
||
@keyframes invBadgePop {
|
||
0%,100%{ transform: scale(1); }
|
||
50% { transform: scale(1.15); }
|
||
}
|
||
|
||
/* ── Active Effect Banner (shown on other screens, e.g. pretest) ── */
|
||
.aeb-wrap {
|
||
display: flex; gap: 0.5rem; flex-wrap: wrap;
|
||
}
|
||
.aeb-pill {
|
||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||
padding: 0.38rem 0.85rem;
|
||
border-radius: 100px;
|
||
font-family: 'Nunito', sans-serif;
|
||
font-size: 0.72rem; font-weight: 900;
|
||
animation: aebPillIn 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
|
||
animation-delay: var(--aeb-delay, 0s);
|
||
}
|
||
@keyframes aebPillIn {
|
||
from { opacity:0; transform: scale(0.8) translateY(6px); }
|
||
to { opacity:1; transform: scale(1) translateY(0); }
|
||
}
|
||
|
||
/* Color variants per effect type */
|
||
.aeb-pill.xp_boost {
|
||
background: rgba(251,191,36,0.12);
|
||
border: 1.5px solid rgba(251,191,36,0.4);
|
||
color: #fbbf24;
|
||
}
|
||
.aeb-pill.streak_shield {
|
||
background: rgba(96,165,250,0.1);
|
||
border: 1.5px solid rgba(96,165,250,0.35);
|
||
color: #60a5fa;
|
||
}
|
||
.aeb-pill.coin_boost {
|
||
background: rgba(167,243,208,0.08);
|
||
border: 1.5px solid rgba(52,211,153,0.35);
|
||
color: #34d399;
|
||
}
|
||
.aeb-pill.default {
|
||
background: rgba(255,255,255,0.06);
|
||
border: 1.5px solid rgba(255,255,255,0.15);
|
||
color: rgba(255,255,255,0.7);
|
||
}
|
||
.aeb-pill-icon { font-size: 0.9rem; line-height:1; }
|
||
.aeb-pill-label { line-height:1; }
|
||
.aeb-pill-time {
|
||
font-family: 'Nunito Sans', sans-serif;
|
||
font-size: 0.58rem; font-weight: 700;
|
||
opacity: 0.55; margin-left: 0.1rem;
|
||
}
|
||
`;
|
||
|
||
const ITEM_ICON: Record<string, string> = {
|
||
xp_boost: "⚡",
|
||
streak_shield: "🛡️",
|
||
title: "🏴☠️",
|
||
coin_boost: "🪙",
|
||
};
|
||
function itemIcon(effectType: string): string {
|
||
return ITEM_ICON[effectType] ?? "📦";
|
||
}
|
||
|
||
// ─── InventoryButton ──────────────────────────────────────────────────────────
|
||
/**
|
||
* Drop-in trigger button. Can be placed in any nav bar, header, or screen.
|
||
* Shows a gold glow + badge count when active effects are running.
|
||
*
|
||
* Usage:
|
||
* <InventoryButton />
|
||
* <InventoryButton label="Hold" />
|
||
*/
|
||
export const InventoryButton = ({}: {}) => {
|
||
const [open, setOpen] = useState(false);
|
||
const activeEffects = useInventoryStore((s) => s.activeEffects);
|
||
const liveEffects = getLiveEffects(activeEffects);
|
||
const hasActive = liveEffects.length > 0;
|
||
|
||
return (
|
||
<>
|
||
<style>{BTN_STYLES}</style>
|
||
|
||
<button
|
||
className={`inv-btn${hasActive ? " has-active" : ""}`}
|
||
onClick={() => setOpen(true)}
|
||
aria-label="Open inventory"
|
||
>
|
||
🎒
|
||
{hasActive && (
|
||
<span className="inv-btn-badge">{liveEffects.length}</span>
|
||
)}
|
||
</button>
|
||
|
||
{open && <InventoryModal onClose={() => setOpen(false)} />}
|
||
</>
|
||
);
|
||
};
|
||
|
||
// ─── ActiveEffectBanner ───────────────────────────────────────────────────────
|
||
/**
|
||
* Shows pills for each currently-active effect.
|
||
* Place wherever you want a contextual reminder (pretest screen, dashboard, etc.)
|
||
*
|
||
* Usage:
|
||
* <ActiveEffectBanner />
|
||
* <ActiveEffectBanner filter="xp_boost" /> ← only show a specific effect
|
||
*
|
||
* Example output on Pretest screen:
|
||
* ⚡ XP Boost ×2 · 1h 42m 🛡️ Streak Shield · 23m
|
||
*/
|
||
export const ActiveEffectBanner = ({
|
||
filter,
|
||
className,
|
||
}: {
|
||
filter?: string;
|
||
className?: string;
|
||
}) => {
|
||
const activeEffects = useInventoryStore((s) => s.activeEffects);
|
||
const live = getLiveEffects(activeEffects).filter(
|
||
(e) => !filter || e.item.effect_type === filter,
|
||
);
|
||
|
||
if (live.length === 0) return null;
|
||
|
||
return (
|
||
<>
|
||
<style>{BTN_STYLES}</style>
|
||
<div className={`aeb-wrap${className ? ` ${className}` : ""}`}>
|
||
{live.map((e, i) => (
|
||
<div
|
||
key={e.id}
|
||
className={`aeb-pill ${e.item.effect_type ?? "default"}`}
|
||
style={{ "--aeb-delay": `${i * 0.07}s` } as React.CSSProperties}
|
||
>
|
||
<span className="aeb-pill-icon">
|
||
{itemIcon(e.item.effect_type)}
|
||
</span>
|
||
<span className="aeb-pill-label">
|
||
{e.item.name}
|
||
{e.item.effect_type === "xp_boost" && e.item.effect_value
|
||
? ` ×${e.item.effect_value}`
|
||
: ""}
|
||
</span>
|
||
<span className="aeb-pill-time">
|
||
{formatTimeLeft(e.expires_at)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
};
|