Compare commits

...

4 Commits

Author SHA1 Message Date
filemon 834038dba5 Merge branch 'main' into fix/blobbi-item-use-cooldown 2026-04-05 23:08:25 -03:00
filemon d6f89d206e Remove quantity leftovers and unify cooldown UX across all item surfaces
Quantity cleanup:
- Remove quantity field from ResolvedInventoryItem, CompanionItem,
  and BlobbiShopModal's local interface
- Remove all quantity: Infinity assignments from item resolution
- Rename filterInventoryByAction to getItemsForAction with a
  cleaner signature (drop unused _storage parameter)
- Update barrel export and call sites
- Fix stale 'inventory' comments in blobbi-action-utils.ts

Cooldown UX consistency:
- Add Clock icon to dashboard ItemsTabContent (previously only
  showed dimmed opacity with no icon)
- Normalize shop modal button variant (outline, matching action
  modal style)
- All three item surfaces now share the same visual language:
  loading = spinner, cooldown = clock icon + disabled + muted
2026-04-05 22:56:11 -03:00
filemon 0249760b74 Add reactive cooldown UI with auto-expiring visual feedback
The item-cooldown module now has a subscriber system and scheduled
timers that automatically notify React when cooldowns start and end.

New useItemCooldown hook (via useSyncExternalStore) gives components
a reactive isOnCooldown(itemId) that triggers re-renders on cooldown
state changes — no polling or manual refresh needed.

Visual behavior:
- Use button shows a clock icon during cooldown (distinct from the
  spinner shown during the loading/mutation phase)
- Button switches to outline/ghost variant while cooling down
- Items tab dims items on cooldown
- All visuals clear automatically when the timer expires
- Success cooldown: 400ms, failure cooldown: 2000ms

Applied consistently across:
- BlobbiActionInventoryModal (feed/play/clean/medicine dialogs)
- ItemsTabContent (dashboard items grid)
- BlobbiShopModal ItemsGrid (shop items tab)

The isItemOnCooldown prop was removed from BlobbiActionInventoryModal
since each component now uses the hook directly.
2026-04-05 22:37:18 -03:00
filemon 377a6cbb97 Unify item-use cooldown with shared module and simplify item flows
Introduce a centralized item-cooldown module (item-cooldown.ts) that
provides a single per-item cooldown map shared across every item-use
path: BlobbiPage dashboard, companion floating UI, shop modal, and
the falling-items system.

Previously, cooldown was only enforced in the companion layer's
fallback hook (useBlobbiItemUse) and HangingItems had its own local
fallback map. The primary dashboard mutation (useBlobbiUseInventoryItem)
had zero cooldown — only the isPending mutex prevented spam. Now all
paths check and set cooldown via the same shared singleton.

Also included in this commit:
- Remove quantity selectors and confirmation dialogs from modals
- Replace multi-quantity stat loops with single-use application
- Drop quantity parameter from UseItemFunction and all call sites
- Remove dead clearItemCooldown code from context chain
- Add isItemOnCooldown prop to BlobbiActionInventoryModal for
  per-item disabled state on Use buttons
- Remove HangingItems local cooldown fallback (delegates to parent)
- Fix HangingItems cooldown key mismatch (now uses item type ID)
2026-04-05 21:54:59 -03:00
15 changed files with 285 additions and 296 deletions
@@ -1,7 +1,7 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import { Loader2, X, Clock } from 'lucide-react';
import {
Dialog,
@@ -16,7 +16,7 @@ import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobb
import { cn } from '@/lib/utils';
import {
filterInventoryByAction,
getItemsForAction,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
@@ -27,6 +27,7 @@ import {
type ResolvedInventoryItem,
type EggStatPreview,
} from '../lib/blobbi-action-utils';
import { useItemCooldown } from '../hooks/useItemCooldown';
interface BlobbiActionInventoryModalProps {
open: boolean;
@@ -53,11 +54,11 @@ export function BlobbiActionInventoryModal({
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
const { isOnCooldown } = useItemCooldown();
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
// Get all available items for this action from the catalog.
const availableItems = useMemo(() => {
return filterInventoryByAction([], action, { stage: companion.stage });
return getItemsForAction(action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
@@ -68,6 +69,7 @@ export function BlobbiActionInventoryModal({
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
if (isOnCooldown(item.itemId)) return;
onUseItem(item.itemId);
};
@@ -126,17 +128,22 @@ export function BlobbiActionInventoryModal({
{/* Item List */}
{canUse && !isEmpty && (
<div className="grid gap-3">
{availableItems.map((item) => (
<BlobbiInventoryUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
{availableItems.map((item) => {
const isCoolingDown = isOnCooldown(item.itemId);
const isThisUsing = isUsingItem && usingItemId === item.itemId;
return (
<BlobbiItemUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleUseItem(item)}
isUsing={isThisUsing}
disabled={isUsingItem || isCoolingDown}
isCoolingDown={isCoolingDown}
/>
);
})}
</div>
)}
</div>
@@ -145,30 +152,32 @@ export function BlobbiActionInventoryModal({
);
}
// ─── Inventory Use Row ────────────────────────────────────────────────────────
// ─── Item Use Row ─────────────────────────────────────────────────────────────
interface BlobbiInventoryUseRowProps {
interface BlobbiItemUseRowProps {
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
onUse: () => void;
isUsing: boolean;
disabled: boolean;
isCoolingDown: boolean;
}
function BlobbiInventoryUseRow({
function BlobbiItemUseRow({
item,
companion,
action,
onUse,
isUsing,
disabled,
}: BlobbiInventoryUseRowProps) {
isCoolingDown,
}: BlobbiItemUseRowProps) {
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes - handle egg-specific preview for medicine and clean
// Preview stat changes — single-use effect preview
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
return {
@@ -217,38 +226,18 @@ function BlobbiInventoryUseRow({
<div className="flex flex-wrap gap-x-3 gap-y-1">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{delta > 0 ? '+' : ''}{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{delta > 0 ? '+' : ''}{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
</span>
))}
</div>
@@ -261,10 +250,13 @@ function BlobbiInventoryUseRow({
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
variant={isCoolingDown ? 'outline' : 'default'}
className="shrink-0 min-w-14"
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
) : isCoolingDown ? (
<Clock className="size-3.5 text-muted-foreground" />
) : (
'Use'
)}
@@ -276,38 +268,18 @@ function BlobbiInventoryUseRow({
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{delta > 0 ? '+' : ''}{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{delta > 0 ? '+' : ''}{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
</span>
))}
</div>
@@ -29,11 +29,12 @@ import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { isItemOnCooldown, setItemCooldown } from '../lib/item-cooldown';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an item on a Blobbi companion
* Request payload for using an item on a Blobbi companion.
*/
export interface UseItemRequest {
itemId: string;
@@ -41,7 +42,7 @@ export interface UseItemRequest {
}
/**
* Result of using an item on a Blobbi companion
* Result of using an item on a Blobbi companion.
*/
export interface UseItemResult {
itemName: string;
@@ -79,15 +80,19 @@ import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an item on a Blobbi companion.
*
*
* Items are reusable abilities sourced from the shop catalog — no
* inventory ownership or quantity is required.
*
* ownership or quantity is required. Each use applies effects once.
*
* Cooldown is enforced via the shared item-cooldown module so that
* rapid repeated clicks are blocked consistently across all UIs.
*
* This hook:
* 1. Validates the companion and item compatibility
* 2. Ensures canonical format before action
* 3. Applies accumulated decay, then item effects to Blobbi stats
* 4. Updates Blobbi state (kind 31124)
* 2. Checks the shared per-item cooldown
* 3. Ensures canonical format before action
* 4. Applies accumulated decay, then item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
*/
export function useBlobbiUseInventoryItem({
companion,
@@ -101,6 +106,11 @@ export function useBlobbiUseInventoryItem({
return useMutation({
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
// ─── Cooldown guard (shared across all UIs) ───
if (isItemOnCooldown(itemId)) {
throw new Error('Please wait before using this item again');
}
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -147,9 +157,6 @@ export function useBlobbiUseInventoryItem({
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any user interaction updates stats.
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
@@ -163,7 +170,6 @@ export function useBlobbiUseInventoryItem({
const statsAfterDecay = decayResult.stats;
// ─── Validate Play Energy Requirements ───
// For play actions, validate the Blobbi has enough energy AFTER decay
if (action === 'play') {
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
const currentEnergy = statsAfterDecay.energy;
@@ -174,8 +180,6 @@ export function useBlobbiUseInventoryItem({
);
}
// Also check if playing would have any effect at all
// If happiness is maxed AND we can't spend energy, playing is pointless
const happinessGain = shopItem.effect.happiness ?? 0;
const currentHappiness = statsAfterDecay.happiness;
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
@@ -194,8 +198,7 @@ export function useBlobbiUseInventoryItem({
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
@@ -243,7 +246,6 @@ export function useBlobbiUseInventoryItem({
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
@@ -252,7 +254,6 @@ export function useBlobbiUseInventoryItem({
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain ───
@@ -276,11 +277,6 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// Items are free to use — no storage decrement needed.
// No query invalidation needed — the optimistic update above keeps the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
return {
itemName: shopItem.name,
action,
@@ -289,7 +285,7 @@ export function useBlobbiUseInventoryItem({
newXP,
};
},
onSuccess: ({ itemName, action, xpGained }) => {
onSuccess: ({ itemName, action, xpGained }, { itemId }) => {
const actionMeta = ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
@@ -297,19 +293,24 @@ export function useBlobbiUseInventoryItem({
description: `Used ${itemName} on your Blobbi. ${xpText}`,
});
// Set shared cooldown (success — short)
setItemCooldown(itemId, true);
// Track daily mission progress
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
onError: (error: Error, { itemId }) => {
toast({
title: 'Failed to use item',
description: error.message,
variant: 'destructive',
});
// Set shared cooldown (failure — longer)
setItemCooldown(itemId, false);
},
});
}
@@ -0,0 +1,46 @@
/**
* useItemCooldown — React hook for per-item cooldown state.
*
* Subscribes to the shared item-cooldown module so that components
* automatically re-render when any item's cooldown starts or expires.
*
* Usage:
* ```tsx
* const { isOnCooldown } = useItemCooldown();
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
* ```
*/
import { useCallback, useSyncExternalStore } from 'react';
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
/** Monotonically increasing snapshot counter bumped on every cooldown change. */
let snapshotVersion = 0;
/** Called by subscribeCooldowns — bumps the version so useSyncExternalStore re-renders. */
function bumpVersion(): void {
snapshotVersion++;
}
// Wire the bump into the cooldown module (idempotent — Set prevents duplicates)
subscribeCooldowns(bumpVersion);
function getSnapshot(): number {
return snapshotVersion;
}
/**
* Hook that re-renders the calling component whenever any item's cooldown
* starts or ends. Returns a stable `isOnCooldown` checker.
*/
export function useItemCooldown() {
// Subscribe to cooldown changes — triggers re-render via snapshot bump
useSyncExternalStore(subscribeCooldowns, getSnapshot);
const isOnCooldown = useCallback((itemId: string): boolean => {
return isItemOnCooldown(itemId);
}, []);
return { isOnCooldown };
}
+1 -1
View File
@@ -136,7 +136,7 @@ export {
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
getItemsForAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
+8 -11
View File
@@ -12,8 +12,8 @@ import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
* Direct actions that don't use items
* These actions affect stats directly without using shop items.
*/
export type DirectAction = 'play_music' | 'sing';
@@ -273,11 +273,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved catalog item with shop metadata
* Resolved catalog item with shop metadata.
*/
export interface ResolvedInventoryItem {
itemId: string;
quantity: number;
name: string;
icon: string;
type: ShopItemCategory;
@@ -285,7 +284,7 @@ export interface ResolvedInventoryItem {
}
/**
* Options for filtering catalog items by action
* Options for filtering catalog items by action.
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
@@ -294,7 +293,7 @@ export interface FilterInventoryOptions {
/**
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
* Items are reusable abilities — no ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
@@ -303,8 +302,7 @@ export interface FilterInventoryOptions {
* - medicine action: only items with health effect
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
_storage: StorageItem[],
export function getItemsForAction(
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
@@ -324,16 +322,15 @@ export function filterInventoryByAction(
// For eggs, filter items by egg-compatible effects
if (isEgg) {
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
continue; // Skip medicine without health effect
continue;
}
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
continue; // Skip hygiene items without hygiene or happiness effect
continue;
}
}
result.push({
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
+93
View File
@@ -0,0 +1,93 @@
/**
* Centralized item-use cooldown tracking.
*
* Provides a single, shared per-item cooldown map used by every item-use
* path (BlobbiPage dashboard, companion layer, shop modal, falling items).
*
* Design:
* - Module-level singleton — all hooks share the same map.
* - Keyed by item type ID (e.g. "food_apple"), NOT instance IDs.
* - Separate durations for success (short) and failure (longer).
* - Built-in subscriber system so React components can re-render when
* cooldowns start or expire.
*/
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown after a successful item use (ms). */
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
/** Cooldown after a failed item use (ms). */
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
// ─── Singleton State ──────────────────────────────────────────────────────────
interface CooldownEntry {
/** Timestamp (Date.now()) when the cooldown expires */
expiresAt: number;
/** Timeout handle that fires the expiry notification */
timerId: ReturnType<typeof setTimeout>;
}
/** Module-level cooldown map shared across all hooks. */
const cooldowns = new Map<string, CooldownEntry>();
/** Subscribers notified on every cooldown start/end. */
const subscribers = new Set<() => void>();
// ─── Internal Helpers ─────────────────────────────────────────────────────────
function notify(): void {
subscribers.forEach((cb) => cb());
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Check whether an item is currently on cooldown.
*/
export function isItemOnCooldown(itemId: string): boolean {
const entry = cooldowns.get(itemId);
if (!entry) return false;
if (Date.now() >= entry.expiresAt) {
clearTimeout(entry.timerId);
cooldowns.delete(itemId);
return false;
}
return true;
}
/**
* Put an item on cooldown after a use attempt.
* Subscribers are notified immediately (cooldown started) and again when
* the cooldown expires (so the UI can re-enable the button).
*/
export function setItemCooldown(itemId: string, success: boolean): void {
// Clear any existing cooldown for this item
const prev = cooldowns.get(itemId);
if (prev) clearTimeout(prev.timerId);
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
const timerId = setTimeout(() => {
cooldowns.delete(itemId);
notify(); // re-render: cooldown ended
}, ms);
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
notify(); // re-render: cooldown started
}
/**
* Subscribe to cooldown state changes.
* Returns an unsubscribe function.
*/
export function subscribeCooldowns(callback: () => void): () => void {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
}
@@ -40,7 +40,8 @@ export {
* 1. Registered function from BlobbiPage (if available) - better cache access
* 2. Built-in useBlobbiItemUse hook as fallback - works anywhere
*
* Uses subscription pattern to only re-render when necessary.
* Cooldown is enforced via the shared item-cooldown module, which is
* consistent across both the registered and fallback paths.
*/
export function useBlobbiActions(): BlobbiActionsContextValue {
const context = useContext(BlobbiActionsContext);
@@ -103,8 +104,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
isUsingItem,
canUseItems,
isItemOnCooldown: fallbackItemUse.isItemOnCooldown,
clearItemCooldown: fallbackItemUse.clearItemCooldown,
}), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown, fallbackItemUse.clearItemCooldown]);
}), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown]);
}
// ─── Registration Hook ────────────────────────────────────────────────────────
@@ -187,5 +187,3 @@ export function useBlobbiActionsRegistration(
};
}, [context]);
}
@@ -47,11 +47,8 @@ export interface BlobbiActionsContextValue {
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
/** Check if a specific item is on cooldown */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
@@ -437,32 +437,15 @@ export function HangingItems({
// Contact auto-use only triggers when item ENTERS the zone (transitions from outside to inside)
const itemsInZoneRef = useRef<Set<string>>(new Set());
// Local item cooldown tracking (fallback if isItemOnCooldown not provided)
const localCooldownsRef = useRef<Map<string, number>>(new Map());
// Check if an item is on cooldown (uses prop if available, else local)
const checkItemCooldown = useCallback((itemId: string): boolean => {
// Check if an item is on cooldown.
// Uses the parent-provided isItemOnCooldown (shared module) with the item TYPE id.
const checkItemCooldown = useCallback((item: CompanionItem): boolean => {
if (isItemOnCooldown) {
return isItemOnCooldown(itemId);
return isItemOnCooldown(item.id);
}
// Local fallback cooldown check
const expiresAt = localCooldownsRef.current.get(itemId);
if (!expiresAt) return false;
if (Date.now() >= expiresAt) {
localCooldownsRef.current.delete(itemId);
return false;
}
return true;
return false;
}, [isItemOnCooldown]);
// Set local cooldown for an item
const setLocalCooldown = useCallback((itemId: string, success: boolean) => {
const cooldownMs = success
? HANGING_CONFIG.successUseCooldown
: HANGING_CONFIG.failedUseCooldown;
localCooldownsRef.current.set(itemId, Date.now() + cooldownMs);
}, []);
// Ref for onItemLanded callback
const onItemLandedRef = useRef(onItemLanded);
onItemLandedRef.current = onItemLanded;
@@ -624,8 +607,8 @@ export function HangingItems({
* @param source - How the item was used
*/
const attemptUseItem = useCallback(async (instanceId: string, item: CompanionItem, source: 'contact' | 'click' | 'drag-drop') => {
// Check cooldown first (prevents retry spam) - use instanceId for cooldown tracking
if (checkItemCooldown(instanceId)) {
// Check shared cooldown by item type ID (prevents retry spam)
if (checkItemCooldown(item)) {
if (import.meta.env.DEV) {
console.log(`[HangingItems] Item on cooldown, skipping:`, item.name, instanceId);
}
@@ -644,8 +627,6 @@ export function HangingItems({
itemsBeingUsedRef.current = new Set(itemsBeingUsedRef.current).add(instanceId);
forceUpdate(c => c + 1); // Trigger re-render for visual feedback
let success = false;
try {
// If onItemUse is provided, use the async flow
const onItemUseFn = onItemUseRef.current;
@@ -656,7 +637,6 @@ export function HangingItems({
const result = await onItemUseFn(item);
if (result.success) {
success = true;
if (import.meta.env.DEV) {
console.log(`[HangingItems] Item used successfully:`, item.name, instanceId);
}
@@ -685,7 +665,6 @@ export function HangingItems({
}
} else {
// Legacy behavior: call onItemCollected and remove immediately
success = true;
if (import.meta.env.DEV) {
console.log(`[HangingItems] Item collected (legacy):`, item.name, instanceId);
}
@@ -712,11 +691,9 @@ export function HangingItems({
newSet.delete(instanceId);
itemsBeingUsedRef.current = newSet;
forceUpdate(c => c + 1);
// Set cooldown (longer on failure to prevent retry spam)
setLocalCooldown(instanceId, success);
// Cooldown is set by the mutation hooks (onSuccess/onError) via the shared module
}
}, [checkItemCooldown, setLocalCooldown]); // Minimal dependencies - rest uses refs
}, [checkItemCooldown]); // Minimal dependencies - rest uses refs
// Contact detection with Blobbi (for auto-use)
//
+1 -3
View File
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
/**
* Normalized item representation for the companion UI.
* This is a simplified view of shop catalog items optimized for rendering.
* A simplified view of shop catalog items optimized for rendering.
*/
export interface CompanionItem {
/** Unique item ID (matches shop item ID) */
@@ -74,8 +74,6 @@ export interface CompanionItem {
emoji: string;
/** Item category */
category: ShopItemCategory;
/** Availability (always Infinity — items are reusable abilities) */
quantity: number;
/** Item effects when used */
effect?: ItemEffect;
}
@@ -8,16 +8,16 @@
* - Fetches companion and profile data if not provided
* - Uses the same item-use logic as BlobbiPage (useBlobbiUseInventoryItem)
* - Works as a standalone hook or can be passed cached data
* - Provides retry protection and cooldown
* - Uses the shared item-cooldown module for per-item cooldown
*
* Architecture:
* - BlobbiCompanionLayer uses this hook directly as a fallback when
* BlobbiPage is not mounted
* - BlobbiPage registers its own item-use function (which has better cache access)
* - Both use the same underlying mutation logic
* - Both use the same underlying mutation logic and shared cooldown
*/
import { useCallback, useRef, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
@@ -54,17 +54,13 @@ import type { DailyMissionAction } from '@/blobbi/actions/lib/daily-missions';
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
import {
isItemOnCooldown,
setItemCooldown,
} from '@/blobbi/actions/lib/item-cooldown';
import type { UseItemFunction } from './BlobbiActionsContextDef';
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown time after a failed item use attempt (ms) */
const ITEM_USE_COOLDOWN_MS = 3000;
/** Cooldown time after a successful item use (ms) - shorter to allow quick successive uses */
const ITEM_USE_SUCCESS_COOLDOWN_MS = 500;
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseBlobbiItemUseOptions {
@@ -80,23 +76,14 @@ export interface UseBlobbiItemUseOptions {
}
export interface UseBlobbiItemUseResult {
/** The item use function - same signature as UseItemFunction */
/** The item use function same signature as UseItemFunction */
useItem: UseItemFunction;
/** Whether item use is available (companion and profile loaded) */
canUseItems: boolean;
/** Whether an item use is currently in progress */
isUsingItem: boolean;
/** Check if an item is on cooldown (recently attempted) */
/** Check if an item is on cooldown (delegates to shared module) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item (e.g., after it's removed) */
clearItemCooldown: (itemId: string) => void;
}
interface ItemCooldownEntry {
/** Timestamp when the cooldown expires */
expiresAt: number;
/** Whether the last attempt succeeded */
wasSuccess: boolean;
}
// ─── Hook Implementation ──────────────────────────────────────────────────────
@@ -104,16 +91,8 @@ interface ItemCooldownEntry {
/**
* Shared Blobbi item-use hook that works anywhere.
*
* This is the "real" item-use logic extracted to be usable from:
* - BlobbiCompanionLayer (floating companion)
* - BlobbiPage (main dashboard)
* - Any other location
*
* Features:
* - Fetches companion/profile data if not provided
* - Identical item-use logic to useBlobbiUseInventoryItem
* - Built-in per-item cooldown/retry protection
* - Works as a direct hook or registered in context
* Uses the centralized item-cooldown module so that cooldown is
* consistent regardless of which UI path triggers the use.
*/
export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlobbiItemUseResult {
const { nostr } = useNostr();
@@ -125,42 +104,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const { profile: fetchedProfile } = useBlobbonautProfile();
const profile = options.profile ?? fetchedProfile;
// Per-item cooldown tracking (ref to avoid re-renders)
const itemCooldowns = useRef<Map<string, ItemCooldownEntry>>(new Map());
// Check if an item is on cooldown
const isItemOnCooldown = useCallback((itemId: string): boolean => {
const entry = itemCooldowns.current.get(itemId);
if (!entry) return false;
const now = Date.now();
if (now >= entry.expiresAt) {
// Cooldown expired, remove it
itemCooldowns.current.delete(itemId);
return false;
}
return true;
}, []);
// Clear cooldown for an item
const clearItemCooldown = useCallback((itemId: string): void => {
itemCooldowns.current.delete(itemId);
}, []);
// Set cooldown for an item
const setItemCooldown = useCallback((itemId: string, success: boolean): void => {
const cooldownMs = success ? ITEM_USE_SUCCESS_COOLDOWN_MS : ITEM_USE_COOLDOWN_MS;
itemCooldowns.current.set(itemId, {
expiresAt: Date.now() + cooldownMs,
wasSuccess: success,
});
}, []);
// Fetch current companion based on profile's currentCompanion
// This is fetched on-demand when needed, not kept in state
const fetchCurrentCompanion = useCallback(async (): Promise<BlobbiCompanion | null> => {
// If companion was provided via options, use that
if (options.companion !== undefined) {
return options.companion ?? null;
}
@@ -184,51 +129,29 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr, user?.pubkey, profile?.currentCompanion, options.companion]);
// Update companion in query cache - optimistic update for immediate UI refresh
// Update companion in query cache
const updateCompanionInCache = useCallback((event: NostrEvent) => {
if (!user?.pubkey || !profile?.currentCompanion) return;
// Parse the new event to get the updated companion
const parsed = parseBlobbiEvent(event);
if (!parsed) {
// Fallback to invalidation if parsing fails
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return;
}
// Optimistically update the blobbi-collection cache
// This ensures the companion layer sees the update immediately
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] } | undefined>(
// Use partial key match - React Query will find any matching query
['blobbi-collection', user.pubkey],
(prev) => {
if (!prev) return prev;
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
const newCompanionsByD = { ...prev.companionsByD, [parsed.d]: parsed };
return { companionsByD: newCompanionsByD, companions: Object.values(newCompanionsByD) };
},
);
// Also invalidate to trigger background refetch (ensures consistency)
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
// Core mutation for using items (always uses once)
// Core mutation for using items (always single-use)
const mutation = useMutation({
mutationFn: async ({
itemId,
@@ -237,6 +160,11 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
itemId: string;
action: InventoryAction;
}): Promise<{ statsChanged: Record<string, number> }> => {
// ─── Cooldown guard (shared across all UIs) ───
if (isItemOnCooldown(itemId)) {
throw new Error('Please wait before using this item again');
}
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -246,38 +174,30 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error('Profile not found');
}
// Fetch fresh companion data
const companion = await fetchCurrentCompanion();
if (!companion) {
throw new Error('No companion selected');
}
// Check stage restrictions
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
throw new Error(message ?? 'This companion cannot use this item');
}
// Validate item exists in shop catalog
const shopItem = getShopItemById(itemId);
if (!shopItem) {
throw new Error('Item not found in catalog');
}
// Validate item can be used by this companion's stage
// This catches egg-only items (like Shell Repair Kit) being used by baby/adult companions
const itemUsability = canUseItemForStage(itemId, companion.stage);
if (!itemUsability.canUse) {
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
}
// For eggs, validate that items have applicable effects
const isEgg = companion.stage === 'egg';
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
throw new Error('This medicine has no effect on eggs');
@@ -295,8 +215,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
lastDecayAt: companion.lastDecayAt,
now,
});
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects (single use) ───
@@ -306,10 +224,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
if (isEggCompanion && action === 'medicine') {
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
statsUpdate.hunger = '100';
@@ -317,43 +233,30 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
} else if (isEggCompanion && action === 'clean') {
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
statsUpdate.happiness = currentHappiness.toString();
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (totalHappinessChange !== 0) {
statsChanged.happiness = totalHappinessChange;
}
const happinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (happinessChange !== 0) statsChanged.happiness = happinessChange;
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
statsUpdate.energy = clampStat(currentStats.energy).toString();
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
statsUpdate.health = clampStat(currentStats.health).toString();
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// Handle interaction counter for tasks
const companionState = companion.state;
let updatedTags = companion.allTags;
if (companionState === 'incubating') {
@@ -362,7 +265,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
updatedTags = incrementInteractionTaskTags(companion.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(companion) ?? {};
const blobbiTags = updateBlobbiTags(updatedTags, {
@@ -379,9 +281,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
updateCompanionInCache(blobbiEvent);
// ─── Invalidate Queries ───
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return { statsChanged };
@@ -395,14 +294,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
});
// Set shared cooldown (success — short)
setItemCooldown(itemId, true);
// Track daily mission progress
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
// Set success cooldown (short)
setItemCooldown(itemId, true);
},
onError: (error: Error, { itemId }) => {
toast({
@@ -411,14 +310,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
variant: 'destructive',
});
// Set failure cooldown (longer)
// Set shared cooldown (failure — longer)
setItemCooldown(itemId, false);
},
});
// Wrapper function that matches UseItemFunction signature and includes cooldown check
// Wrapper function that matches UseItemFunction signature
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Check cooldown first
// Check shared cooldown first
if (isItemOnCooldown(itemId)) {
if (import.meta.env.DEV) {
console.log('[useBlobbiItemUse] Item on cooldown, skipping:', itemId);
@@ -441,7 +340,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}, [mutation, isItemOnCooldown]);
}, [mutation]);
// Determine if items can be used
const canUseItems = useMemo(() => {
@@ -453,6 +352,5 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
canUseItems,
isUsingItem: mutation.isPending,
isItemOnCooldown,
clearItemCooldown,
};
}
@@ -112,7 +112,6 @@ function resolveItemsForAction(
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: Infinity,
effect: shopItem.effect,
});
}
@@ -215,7 +215,7 @@ export function BlobbiInventoryModal({
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</div>
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
<DialogTitle className="text-xl sm:text-2xl">Items</DialogTitle>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { ShoppingBag, Package, Loader2, X } from 'lucide-react';
import { ShoppingBag, Package, Loader2, X, Clock } from 'lucide-react';
import {
Dialog,
@@ -19,6 +19,7 @@ import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobb
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { useItemCooldown } from '@/blobbi/actions/hooks/useItemCooldown';
import { cn, formatCompactNumber } from '@/lib/utils';
type TopTab = 'items' | 'shop';
@@ -271,6 +272,8 @@ interface ItemsGridProps {
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
const { isOnCooldown } = useItemCooldown();
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
@@ -289,6 +292,8 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _on
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map(item => {
const isThisUsing = isUsingItem && usingItemId === item.itemId;
const isCoolingDown = isOnCooldown(item.itemId);
const isDisabled = isUsingItem || isCoolingDown;
return (
<div
@@ -312,10 +317,12 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _on
variant="outline"
className="w-full h-7 text-xs"
onClick={() => onUseItem(item)}
disabled={isUsingItem}
disabled={isDisabled}
>
{isThisUsing ? (
<Loader2 className="size-3 animate-spin" />
) : isCoolingDown ? (
<Clock className="size-3 text-muted-foreground" />
) : (
'Use'
)}
+9 -3
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap } from 'lucide-react';
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap, Clock } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
@@ -87,6 +87,7 @@ import {
// DailyMissionsPanel no longer used — daily missions rendered inline in MissionsTabContent
import { BlobbiOnboardingFlow } from '@/blobbi/onboarding';
import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/companion/interaction';
import { useItemCooldown } from '@/blobbi/actions/hooks/useItemCooldown';
import { BlobbiDevEditor, useBlobbiDevUpdate, type BlobbiDevUpdates, BlobbiEmotionPanel, useEffectiveEmotion, isLocalhostDev } from '@/blobbi/dev';
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
@@ -1972,20 +1973,24 @@ function ItemsTabContent({
isUsingItem,
usingItemId,
}: ItemsTabContentProps) {
const { isOnCooldown } = useItemCooldown();
return (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-0.5">
{allShopItems.filter(i => i.status !== 'disabled').map((item) => {
const isThisUsing = isUsingItem && usingItemId === item.id;
const isCoolingDown = isOnCooldown(item.id);
const isDisabled = isUsingItem || isCoolingDown;
return (
<button
key={item.id}
onClick={() => onUseItem(item.id)}
disabled={isUsingItem}
disabled={isDisabled}
className={cn(
'group relative flex flex-col items-center justify-center gap-0.5 py-3 rounded-2xl transition-all duration-200',
'hover:bg-accent/50 hover:-translate-y-0.5 active:scale-[0.93] active:translate-y-0',
isThisUsing && 'bg-accent/40 -translate-y-0.5',
isUsingItem && !isThisUsing && 'opacity-40 pointer-events-none',
isDisabled && !isThisUsing && 'opacity-40 pointer-events-none',
)}
>
{/* Stat category indicator — top-right */}
@@ -1995,6 +2000,7 @@ function ItemsTabContent({
<span className="text-4xl leading-none transition-transform duration-200 group-hover:scale-110">{item.icon}</span>
<span className="text-[10px] text-muted-foreground font-medium truncate w-full text-center px-1">{item.name}</span>
{isThisUsing && <Loader2 className="size-3 animate-spin text-primary absolute bottom-1" />}
{isCoolingDown && !isThisUsing && <Clock className="size-3 text-muted-foreground absolute bottom-1" />}
</button>
);
})}