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
This commit is contained in:
filemon
2026-04-05 22:56:11 -03:00
parent 0249760b74
commit d6f89d206e
7 changed files with 25 additions and 33 deletions
@@ -16,7 +16,7 @@ import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobb
import { cn } from '@/lib/utils';
import {
filterInventoryByAction,
getItemsForAction,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
@@ -58,7 +58,7 @@ export function BlobbiActionInventoryModal({
// 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
+1 -1
View File
@@ -136,7 +136,7 @@ export {
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
getItemsForAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
+16 -19
View File
@@ -7,18 +7,18 @@ import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Actions that consume inventory items
* Item-based care actions (use a shop catalog item on the companion)
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Non-inventory actions that don't consume items
* Direct actions that don't use items
* These actions affect stats directly without using shop items.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (inventory + direct)
* All Blobbi actions (item-based + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
};
/**
* Action metadata for UI display (inventory actions)
* Action metadata for UI display (item-based care actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
@@ -59,7 +59,7 @@ export const ACTION_METADATA: Record<InventoryAction, { label: string; descripti
};
/**
* Action metadata for direct actions (non-inventory)
* Action metadata for direct actions (no item required)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
@@ -270,14 +270,13 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Inventory Helpers ────────────────────────────────────────────────────────
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved inventory 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 inventory 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,
@@ -374,7 +371,7 @@ export function decrementStorageItem(
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general inventory items (food, toys, hygiene)
* Stages that can use general items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
@@ -407,14 +404,14 @@ export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_m
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific inventory action.
* Check if a companion can use a specific item action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all inventory actions at the domain layer.
// All stages can technically use all item actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
@@ -440,7 +437,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
}
/**
* Check if a companion can use general inventory items (feed, play, clean).
* Check if a companion can use general items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
+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 inventory 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;
/** Quantity available in inventory */
quantity: number;
/** Item effects when used */
effect?: ItemEffect;
}
@@ -112,7 +112,6 @@ function resolveItemsForAction(
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: Infinity,
effect: shopItem.effect,
});
}
@@ -24,10 +24,9 @@ import { cn, formatCompactNumber } from '@/lib/utils';
type TopTab = 'items' | 'shop';
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
@@ -93,7 +92,6 @@ export function BlobbiShopModal({
result.push({
...item,
itemId: item.id,
quantity: Infinity,
canUse: usability.canUse,
reason: usability.reason,
});
@@ -316,7 +314,7 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _on
{item.canUse ? (
<Button
size="sm"
variant={isCoolingDown ? 'ghost' : 'outline'}
variant="outline"
className="w-full h-7 text-xs"
onClick={() => onUseItem(item)}
disabled={isDisabled}
+3 -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';
@@ -1990,8 +1990,7 @@ function ItemsTabContent({
'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',
isCoolingDown && !isThisUsing && 'opacity-40',
isDisabled && !isThisUsing && !isCoolingDown && 'opacity-40 pointer-events-none',
isDisabled && !isThisUsing && 'opacity-40 pointer-events-none',
)}
>
{/* Stat category indicator — top-right */}
@@ -2001,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>
);
})}