Remove inventory ownership requirement from Blobbi companion items
Items are now treated as abilities/tools unlocked by stage, not consumable inventory that must be purchased. All catalog items are shown in the companion action menu regardless of inventory quantity. Changes: - Source items from shop catalog instead of user inventory storage - Remove quantity validation and storage decrement on item use - Remove quantity badges and 'in inventory' text from all modals - Keep stage-based filtering (egg vs baby/adult restrictions) - Cap quantity selector at 99 instead of inventory count
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
|
||||
import { Loader2, Minus, Plus, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -49,9 +49,9 @@ export function BlobbiActionInventoryModal({
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
profile,
|
||||
profile: _profile,
|
||||
onUseItem,
|
||||
onOpenShop,
|
||||
onOpenShop: _onOpenShop,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
}: BlobbiActionInventoryModalProps) {
|
||||
@@ -62,11 +62,11 @@ export function BlobbiActionInventoryModal({
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// Filter inventory by action type, respecting egg-compatible effects
|
||||
// Get all available items for this action from the catalog (not inventory).
|
||||
// Items are abilities/tools — no ownership required.
|
||||
const availableItems = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
|
||||
}, [profile, action, companion.stage]);
|
||||
return filterInventoryByAction([], action, { stage: companion.stage });
|
||||
}, [action, companion.stage]);
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
const canUse = canUseAction(companion, action);
|
||||
@@ -98,13 +98,8 @@ export function BlobbiActionInventoryModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShop = () => {
|
||||
onOpenChange(false);
|
||||
onOpenShop();
|
||||
};
|
||||
|
||||
// Quantity controls
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
// Quantity controls - items are unlimited abilities, cap at a reasonable max
|
||||
const maxQuantity = 99;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -161,14 +156,10 @@ export function BlobbiActionInventoryModal({
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">{actionMeta.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-4">
|
||||
You don't have any items for this action. Visit the shop to get some!
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
No items are available for this action at your Blobbi's current stage.
|
||||
</p>
|
||||
<Button onClick={handleOpenShop} className="gap-2">
|
||||
<ShoppingBag className="size-4" />
|
||||
Open Shop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -280,9 +271,6 @@ function BlobbiInventoryUseRow({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
x{item.quantity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Effect Preview - shown inline on desktop */}
|
||||
@@ -502,9 +490,6 @@ function BlobbiUseItemConfirmDialog({
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -512,9 +497,6 @@ function BlobbiUseItemConfirmDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -9,16 +9,12 @@ import { toast } from '@/hooks/useToast';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
clampStat,
|
||||
@@ -102,7 +98,7 @@ export function useBlobbiUseInventoryItem({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
updateProfileEvent: _updateProfileEvent,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -391,28 +387,8 @@ export function useBlobbiUseInventoryItem({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// Only decrement storage if the item actually exists in inventory.
|
||||
// Items are free to use regardless of inventory state.
|
||||
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
|
||||
if (hasItemInStorage) {
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
// No query invalidation needed — the optimistic updates above keep the
|
||||
// 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).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
|
||||
// ─── Action Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter inventory items by action type.
|
||||
* Returns resolved items with shop metadata.
|
||||
* Get all available items for an action type from the shop catalog.
|
||||
* Items are abilities/tools — no inventory ownership is required.
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Only items matching the action's item type are included
|
||||
@@ -304,22 +304,20 @@ export interface FilterInventoryOptions {
|
||||
* - clean action: only items with hygiene or happiness effect
|
||||
*/
|
||||
export function filterInventoryByAction(
|
||||
storage: StorageItem[],
|
||||
_storage: StorageItem[],
|
||||
action: InventoryAction,
|
||||
options: FilterInventoryOptions = {}
|
||||
): ResolvedInventoryItem[] {
|
||||
const allowedType = ACTION_TO_ITEM_TYPE[action];
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
const isEgg = options.stage === 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
for (const storageItem of storage) {
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== allowedType) continue;
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
// Shell Repair Kit: only show for eggs in medicine modal
|
||||
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -334,8 +332,8 @@ export function filterInventoryByAction(
|
||||
}
|
||||
|
||||
result.push({
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: shopItem.id,
|
||||
quantity: Infinity,
|
||||
name: shopItem.name,
|
||||
icon: shopItem.icon,
|
||||
type: shopItem.type,
|
||||
|
||||
@@ -406,7 +406,7 @@ export function HangingItems({
|
||||
|
||||
// Track how many instances of each item type have been released (not yet used)
|
||||
// Key: item.id (type ID), Value: count of released instances
|
||||
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
|
||||
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Counter for generating unique instance IDs
|
||||
const instanceCounterRef = useRef(0);
|
||||
@@ -985,15 +985,9 @@ export function HangingItems({
|
||||
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
|
||||
};
|
||||
|
||||
// Calculate hanging items with their remaining quantities
|
||||
// An item appears in the hanging row if (quantity - releasedCount) > 0
|
||||
const hangingItems = items
|
||||
.map(item => {
|
||||
const releasedCount = releasedCountByItemId.get(item.id) || 0;
|
||||
const remainingQuantity = item.quantity - releasedCount;
|
||||
return { ...item, quantity: remainingQuantity };
|
||||
})
|
||||
.filter(item => item.quantity > 0);
|
||||
// All items are always visible — they are abilities, not consumable inventory.
|
||||
// No quantity filtering needed.
|
||||
const hangingItems = items;
|
||||
|
||||
// Should we render the hanging container?
|
||||
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
|
||||
@@ -1033,7 +1027,7 @@ export function HangingItems({
|
||||
>
|
||||
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
|
||||
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1102,8 +1096,8 @@ export function HangingItems({
|
||||
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
|
||||
}}
|
||||
onClick={() => handleItemClick(item, itemX)}
|
||||
title={`${item.name} (x${item.quantity})`}
|
||||
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
|
||||
title={item.name}
|
||||
aria-label={`${item.name}. Click to release.`}
|
||||
>
|
||||
{/* Item emoji */}
|
||||
<span
|
||||
@@ -1114,24 +1108,6 @@ export function HangingItems({
|
||||
>
|
||||
{item.emoji}
|
||||
</span>
|
||||
|
||||
{/* Quantity badge */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-1",
|
||||
"flex items-center justify-center",
|
||||
"bg-primary text-primary-foreground",
|
||||
"text-xs font-semibold rounded-full",
|
||||
"shadow-md"
|
||||
)}
|
||||
style={{
|
||||
minWidth: HANGING_CONFIG.badgeSize,
|
||||
height: HANGING_CONFIG.badgeSize,
|
||||
padding: '0 5px',
|
||||
}}
|
||||
>
|
||||
{item.quantity}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -30,10 +30,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
parseBlobbiEvent,
|
||||
isValidBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
@@ -41,7 +38,6 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
canUseItemForStage,
|
||||
getStageRestrictionMessage,
|
||||
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch profile if not provided
|
||||
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
|
||||
const { profile: fetchedProfile } = useBlobbonautProfile();
|
||||
const profile = options.profile ?? fetchedProfile;
|
||||
|
||||
// Per-item cooldown tracking (ref to avoid re-renders)
|
||||
@@ -283,15 +279,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -414,24 +401,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
|
||||
updateCompanionInCache(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(profile.allTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
// Items are free to use — no storage decrement needed.
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
|
||||
|
||||
return { statsChanged };
|
||||
|
||||
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
import type {
|
||||
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve inventory items for a specific action/category.
|
||||
* Resolve available items for a specific action/category from the shop catalog.
|
||||
*
|
||||
* Items are sourced from the full catalog (not inventory) — all items are
|
||||
* available as abilities/tools unlocked by stage, not consumable inventory.
|
||||
*
|
||||
* Uses the centralized `canUseItemForStage` function to ensure consistent
|
||||
* stage-based filtering across all UIs:
|
||||
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
|
||||
* filters out all egg-only items from the companion interaction system.
|
||||
*/
|
||||
function resolveItemsForAction(
|
||||
storage: StorageItem[],
|
||||
action: CompanionMenuAction,
|
||||
stage: 'egg' | 'baby' | 'adult'
|
||||
): CompanionItem[] {
|
||||
@@ -89,13 +90,10 @@ function resolveItemsForAction(
|
||||
// Sleep action has no items
|
||||
if (!category) return [];
|
||||
|
||||
const allItems = getLiveShopItems();
|
||||
const items: CompanionItem[] = [];
|
||||
|
||||
for (const storageItem of storage) {
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== category) continue;
|
||||
|
||||
// Use centralized stage-based filtering
|
||||
@@ -104,17 +102,17 @@ function resolveItemsForAction(
|
||||
// - Food/Toys: only for baby/adult (excluded for eggs)
|
||||
// - Medicine: must have health effect
|
||||
// - Hygiene: must have hygiene or happiness effect
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
const usability = canUseItemForStage(shopItem.id, stage);
|
||||
if (!usability.canUse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: storageItem.itemId,
|
||||
id: shopItem.id,
|
||||
name: shopItem.name,
|
||||
emoji: shopItem.icon,
|
||||
category: shopItem.type,
|
||||
quantity: storageItem.quantity,
|
||||
quantity: Infinity,
|
||||
effect: shopItem.effect,
|
||||
});
|
||||
}
|
||||
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve items for this action
|
||||
const items = resolveItemsForAction(profile.storage, action, stage);
|
||||
// Resolve items for this action from the catalog (not inventory)
|
||||
const items = resolveItemsForAction(action, stage);
|
||||
|
||||
setMenuState(prev => ({
|
||||
...prev,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { getLiveShopItems } from '../lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ItemEffectDisplay } from './ItemEffectDisplay';
|
||||
@@ -55,7 +55,7 @@ interface BlobbiInventoryContentProps {
|
||||
}
|
||||
|
||||
export function BlobbiInventoryContent({
|
||||
profile,
|
||||
profile: _profile,
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem = false,
|
||||
@@ -65,26 +65,23 @@ export function BlobbiInventoryContent({
|
||||
const [showUseDialog, setShowUseDialog] = useState(false);
|
||||
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
for (const item of allItems) {
|
||||
const usability = canUseItemForStage(item.id, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: item.id,
|
||||
quantity: Infinity,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
}, [companion?.stage]);
|
||||
|
||||
const isEmpty = inventoryItems.length === 0;
|
||||
|
||||
@@ -111,7 +108,7 @@ export function BlobbiInventoryContent({
|
||||
}
|
||||
};
|
||||
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const maxQuantity = 99;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -131,9 +128,9 @@ export function BlobbiInventoryContent({
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
|
||||
No items are available for your Blobbi's current stage.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -179,11 +176,6 @@ export function BlobbiInventoryContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Badge */}
|
||||
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
|
||||
×{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
@@ -367,9 +359,6 @@ function InventoryUseConfirmDialog({
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -377,9 +366,6 @@ function InventoryUseConfirmDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { getLiveShopItems } from '../lib/blobbi-shop-items';
|
||||
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
@@ -80,28 +80,25 @@ export function BlobbiShopModal({
|
||||
|
||||
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
|
||||
|
||||
// ── Inventory items resolution ──
|
||||
// ── Items resolution — sourced from the full catalog (not inventory) ──
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
const allCatalogItems = getLiveShopItems();
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
for (const item of allCatalogItems) {
|
||||
const usability = canUseItemForStage(item.id, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: item.id,
|
||||
quantity: Infinity,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
}, [companion?.stage]);
|
||||
|
||||
// ── Inventory use item handler ──
|
||||
const [usingItemId, setUsingItemId] = useState<string | null>(null);
|
||||
@@ -138,7 +135,7 @@ export function BlobbiShopModal({
|
||||
Items
|
||||
{!inventoryEmpty && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
|
||||
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
|
||||
{inventoryItems.length}
|
||||
</Badge>
|
||||
)}
|
||||
{topTab === 'items' && (
|
||||
@@ -275,20 +272,16 @@ interface ItemsGridProps {
|
||||
onGoToShop: () => void;
|
||||
}
|
||||
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No items yet. Visit the shop to stock up!
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No items are available for your Blobbi's current stage.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
|
||||
<ShoppingBag className="size-3.5" />
|
||||
Browse Shop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -308,13 +301,6 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: I
|
||||
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Quantity badge */}
|
||||
<Badge
|
||||
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
|
||||
>
|
||||
{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user