refactor: reorganize Blobbi shop into domain-scoped structure with list layout
- Move shop module to src/blobbi/shop/ for clear domain boundaries
- Rename components with Blobbi prefix for clarity:
- ShopModal → BlobbiShopModal
- InventoryModal → BlobbiInventoryModal
- PurchaseDialog → BlobbiPurchaseDialog
- usePurchaseItem → useBlobbiPurchaseItem
- shop-items.ts → blobbi-shop-items.ts
- shop.ts → shop.types.ts
- Convert shop UI from card grid to vertical list layout:
- Replace ShopItemCard with BlobbiShopItemRow
- Horizontal row layout: icon, name, category, effects, price, button
- More compact and scannable design
- Better mobile responsiveness
- Easier to compare items at a glance
- Add blobbi-shop-utils.ts with helper functions:
- formatEffectSummary() for compact effect display
- getEffectCounts() for stat summaries
- getPrimaryEffect() for tooltips/badges
- Folder structure:
src/blobbi/shop/
components/ (UI components)
hooks/ (purchase hook)
lib/ (catalog + utils)
types/ (TypeScript types)
- Update all imports in BlobbiPage to use new paths
- Remove old generic shop paths (src/components/shop, etc.)
- Preserve all existing behavior and purchase flow
- No Lightning/sats/kind 40100/40101 (as specified)
- TypeScript strict, no any types used
This commit is contained in:
+3
-3
@@ -10,16 +10,16 @@ import {
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
import type { BlobbonautProfile } from '@/lib/blobbi';
|
||||
import { getShopItemById } from '@/lib/shop-items';
|
||||
import { getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InventoryModalProps {
|
||||
interface BlobbiInventoryModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
profile: BlobbonautProfile | null;
|
||||
}
|
||||
|
||||
export function InventoryModal({ open, onOpenChange, profile }: InventoryModalProps) {
|
||||
export function BlobbiInventoryModal({ open, onOpenChange, profile }: BlobbiInventoryModalProps) {
|
||||
// Resolve storage items with their metadata from the shop catalog
|
||||
const inventoryItems = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
+4
-4
@@ -12,10 +12,10 @@ import {
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import type { ShopItem } from '@/types/shop';
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PurchaseDialogProps {
|
||||
interface BlobbiPurchaseDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ShopItem;
|
||||
@@ -24,14 +24,14 @@ interface PurchaseDialogProps {
|
||||
isPurchasing: boolean;
|
||||
}
|
||||
|
||||
export function PurchaseDialog({
|
||||
export function BlobbiPurchaseDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
availableCoins,
|
||||
onPurchase,
|
||||
isPurchasing,
|
||||
}: PurchaseDialogProps) {
|
||||
}: BlobbiPurchaseDialogProps) {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
|
||||
// Calculate max affordable quantity
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { formatEffectSummary } from '../lib/blobbi-shop-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BlobbiShopItemRowProps {
|
||||
item: ShopItem;
|
||||
availableCoins: number;
|
||||
onPurchaseClick: (item: ShopItem) => void;
|
||||
}
|
||||
|
||||
export function BlobbiShopItemRow({ item, availableCoins, onPurchaseClick }: BlobbiShopItemRowProps) {
|
||||
const isDisabled = item.status === 'disabled';
|
||||
const isAffordable = !isDisabled && availableCoins >= item.price;
|
||||
|
||||
const effectSummary = formatEffectSummary(item.effect);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg border transition-all',
|
||||
'bg-card/60 backdrop-blur-sm',
|
||||
isAffordable && !isDisabled && 'hover:border-primary/30 hover:bg-accent/30',
|
||||
isDisabled && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* Item Icon */}
|
||||
<div className="shrink-0">
|
||||
<div className="relative size-12 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-3xl sm:text-4xl">
|
||||
{item.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{effectSummary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price & Purchase Button */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 hidden sm:inline-flex">
|
||||
{item.price} coins
|
||||
</Badge>
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 sm:hidden text-xs px-2">
|
||||
{item.price}
|
||||
</Badge>
|
||||
<Button
|
||||
variant={isAffordable && !isDisabled ? 'default' : 'secondary'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'min-w-[90px]',
|
||||
isAffordable && !isDisabled && 'bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600'
|
||||
)}
|
||||
onClick={() => onPurchaseClick(item)}
|
||||
disabled={!isAffordable || isDisabled}
|
||||
>
|
||||
{isDisabled ? (
|
||||
'Coming Soon'
|
||||
) : !isAffordable ? (
|
||||
<span className="text-xs sm:text-sm">No Coins</span>
|
||||
) : (
|
||||
'Purchase'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
-12
@@ -9,16 +9,16 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
import { ShopItemCard } from './ShopItemCard';
|
||||
import { PurchaseDialog } from './PurchaseDialog';
|
||||
import { BlobbiShopItemRow } from './BlobbiShopItemRow';
|
||||
import { BlobbiPurchaseDialog } from './BlobbiPurchaseDialog';
|
||||
|
||||
import type { ShopItem, ShopItemCategory } from '@/types/shop';
|
||||
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
|
||||
import type { BlobbonautProfile } from '@/lib/blobbi';
|
||||
import { getShopItemsByType } from '@/lib/shop-items';
|
||||
import { usePurchaseItem } from '@/hooks/usePurchaseItem';
|
||||
import { getShopItemsByType } from '../lib/blobbi-shop-items';
|
||||
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ShopModalProps {
|
||||
interface BlobbiShopModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
profile: BlobbonautProfile | null;
|
||||
@@ -36,12 +36,12 @@ const CATEGORIES: Array<{
|
||||
{ type: 'accessory', label: 'Accessories', icon: <Palette className="size-4" /> },
|
||||
];
|
||||
|
||||
export function ShopModal({ open, onOpenChange, profile }: ShopModalProps) {
|
||||
export function BlobbiShopModal({ open, onOpenChange, profile }: BlobbiShopModalProps) {
|
||||
const [activeCategory, setActiveCategory] = useState<ShopItemCategory>('food');
|
||||
const [selectedItem, setSelectedItem] = useState<ShopItem | null>(null);
|
||||
const [showPurchaseDialog, setShowPurchaseDialog] = useState(false);
|
||||
|
||||
const { mutate: purchaseItem, isPending: isPurchasing } = usePurchaseItem(profile);
|
||||
const { mutate: purchaseItem, isPending: isPurchasing } = useBlobbiPurchaseItem(profile);
|
||||
|
||||
const availableCoins = profile?.coins ?? 0;
|
||||
const items = getShopItemsByType(activeCategory);
|
||||
@@ -136,11 +136,11 @@ export function ShopModal({ open, onOpenChange, profile }: ShopModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items Grid */}
|
||||
{/* Items List */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
{items.map(item => (
|
||||
<ShopItemCard
|
||||
<BlobbiShopItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
availableCoins={availableCoins}
|
||||
@@ -154,7 +154,7 @@ export function ShopModal({ open, onOpenChange, profile }: ShopModalProps) {
|
||||
|
||||
{/* Purchase Dialog */}
|
||||
{selectedItem && (
|
||||
<PurchaseDialog
|
||||
<BlobbiPurchaseDialog
|
||||
open={showPurchaseDialog}
|
||||
onOpenChange={setShowPurchaseDialog}
|
||||
item={selectedItem}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { toast } from './useToast';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { PurchaseRequest } from '@/types/shop';
|
||||
import type { PurchaseRequest } from '../types/shop.types';
|
||||
import type { BlobbonautProfile, StorageItem } from '@/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/lib/blobbi';
|
||||
import { getShopItemById } from '@/lib/shop-items';
|
||||
import { getShopItemById } from '../lib/blobbi-shop-items';
|
||||
|
||||
/**
|
||||
* Hook to purchase items from the Blobbi Shop.
|
||||
@@ -22,7 +22,7 @@ import { getShopItemById } from '@/lib/shop-items';
|
||||
* - Atomic profile update (coins + storage in single event)
|
||||
* - Optimistic updates and error handling
|
||||
*/
|
||||
export function usePurchaseItem(currentProfile: BlobbonautProfile | null) {
|
||||
export function useBlobbiPurchaseItem(currentProfile: BlobbonautProfile | null) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1,12 +1,12 @@
|
||||
// src/lib/shop-items.ts
|
||||
// src/blobbi/shop/lib/blobbi-shop-items.ts
|
||||
|
||||
import type { ShopItem, ShopItemCategory } from '@/types/shop';
|
||||
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
|
||||
|
||||
/**
|
||||
* Complete shop item catalog for the Blobbi Shop.
|
||||
* Based on the specification from /docs/blobbi/blobbi-shop-spec.md
|
||||
*/
|
||||
export const SHOP_ITEMS: ShopItem[] = [
|
||||
export const BLOBBI_SHOP_ITEMS: ShopItem[] = [
|
||||
// ─── Food Items ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'food_apple',
|
||||
@@ -216,21 +216,21 @@ export const SHOP_ITEMS: ShopItem[] = [
|
||||
* Get a shop item by its ID
|
||||
*/
|
||||
export function getShopItemById(id: string): ShopItem | undefined {
|
||||
return SHOP_ITEMS.find(item => item.id === id);
|
||||
return BLOBBI_SHOP_ITEMS.find(item => item.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shop items for a specific category
|
||||
*/
|
||||
export function getShopItemsByType(type: ShopItemCategory): ShopItem[] {
|
||||
return SHOP_ITEMS.filter(item => item.type === type);
|
||||
return BLOBBI_SHOP_ITEMS.filter(item => item.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all live (non-disabled) shop items
|
||||
*/
|
||||
export function getLiveShopItems(): ShopItem[] {
|
||||
return SHOP_ITEMS.filter(item => item.status === 'live');
|
||||
return BLOBBI_SHOP_ITEMS.filter(item => item.status === 'live');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,81 @@
|
||||
// src/blobbi/shop/lib/blobbi-shop-utils.ts
|
||||
|
||||
import type { ItemEffect } from '../types/shop.types';
|
||||
|
||||
/**
|
||||
* Format item effects as a concise summary string for display in list rows.
|
||||
* Shows up to 3 effects in a compact format.
|
||||
*
|
||||
* @example
|
||||
* formatEffectSummary({ hunger: 15, hygiene: -2, energy: 5 })
|
||||
* // Returns: "+15 hunger, -2 hygiene, +5 energy"
|
||||
*/
|
||||
export function formatEffectSummary(effect: ItemEffect | undefined): string {
|
||||
if (!effect || Object.keys(effect).length === 0) {
|
||||
return 'No effects';
|
||||
}
|
||||
|
||||
const effectEntries = Object.entries(effect)
|
||||
.filter(([_, value]) => value !== undefined)
|
||||
.slice(0, 3); // Show max 3 effects for compactness
|
||||
|
||||
return effectEntries
|
||||
.map(([stat, value]) => {
|
||||
const sign = value > 0 ? '+' : '';
|
||||
const statName = stat.replace('_', ' ');
|
||||
return `${sign}${value} ${statName}`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of positive and negative effects for an item.
|
||||
* Useful for displaying quick stat summaries.
|
||||
*/
|
||||
export function getEffectCounts(effect: ItemEffect | undefined): { positive: number; negative: number } {
|
||||
if (!effect) {
|
||||
return { positive: 0, negative: 0 };
|
||||
}
|
||||
|
||||
let positive = 0;
|
||||
let negative = 0;
|
||||
|
||||
for (const value of Object.values(effect)) {
|
||||
if (value !== undefined) {
|
||||
if (value > 0) positive++;
|
||||
else if (value < 0) negative++;
|
||||
}
|
||||
}
|
||||
|
||||
return { positive, negative };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a short, readable effect description for tooltips or badges.
|
||||
* Returns the most significant effect (largest absolute value).
|
||||
*/
|
||||
export function getPrimaryEffect(effect: ItemEffect | undefined): string | null {
|
||||
if (!effect || Object.keys(effect).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let maxEffect: [string, number] | null = null;
|
||||
let maxAbsValue = 0;
|
||||
|
||||
for (const [stat, value] of Object.entries(effect)) {
|
||||
if (value !== undefined) {
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue > maxAbsValue) {
|
||||
maxAbsValue = absValue;
|
||||
maxEffect = [stat, value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!maxEffect) return null;
|
||||
|
||||
const [stat, value] = maxEffect;
|
||||
const sign = value > 0 ? '+' : '';
|
||||
const statName = stat.replace('_', ' ');
|
||||
return `${sign}${value} ${statName}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/types/shop.ts
|
||||
// src/blobbi/shop/types/shop.types.ts
|
||||
|
||||
/**
|
||||
* Shop item category
|
||||
* Shop item category for Blobbi items
|
||||
*/
|
||||
export type ShopItemCategory =
|
||||
| 'food'
|
||||
@@ -11,7 +11,7 @@ export type ShopItemCategory =
|
||||
| 'accessory';
|
||||
|
||||
/**
|
||||
* Stat effects that items can apply
|
||||
* Stat effects that items can apply to Blobbi
|
||||
*/
|
||||
export interface ItemEffect {
|
||||
hunger?: number;
|
||||
@@ -25,7 +25,7 @@ export interface ItemEffect {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shop item definition
|
||||
* Shop item definition for Blobbi shop
|
||||
*/
|
||||
export interface ShopItem {
|
||||
id: string;
|
||||
@@ -38,7 +38,7 @@ export interface ShopItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored item in user's profile inventory
|
||||
* Stored item in Blobbonaut profile inventory
|
||||
*/
|
||||
export interface StorageItem {
|
||||
itemId: string; // Must match a ShopItem.id
|
||||
@@ -46,7 +46,7 @@ export interface StorageItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase request payload
|
||||
* Purchase request payload for Blobbi shop
|
||||
*/
|
||||
export interface PurchaseRequest {
|
||||
itemId: string;
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { ShopItem } from '@/types/shop';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ShopItemCardProps {
|
||||
item: ShopItem;
|
||||
availableCoins: number;
|
||||
onPurchaseClick: (item: ShopItem) => void;
|
||||
}
|
||||
|
||||
export function ShopItemCard({ item, availableCoins, onPurchaseClick }: ShopItemCardProps) {
|
||||
const isDisabled = item.status === 'disabled';
|
||||
const isAffordable = !isDisabled && availableCoins >= item.price;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-4 rounded-xl border transition-all',
|
||||
'bg-card/60 backdrop-blur-sm',
|
||||
isAffordable && !isDisabled && 'hover:border-primary/30 hover:shadow-md hover:scale-[1.02]',
|
||||
isDisabled && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* Item Icon with gradient background */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className="relative size-20 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-5xl">
|
||||
{item.icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0">
|
||||
{item.price} coins
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Item Name */}
|
||||
<h3 className="font-semibold text-center mb-2 truncate">{item.name}</h3>
|
||||
|
||||
{/* Effect Badges */}
|
||||
{item.effect && Object.keys(item.effect).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-center mb-3 min-h-[28px]">
|
||||
{Object.entries(item.effect).map(([stat, value]) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
value > 0 ? 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300' : 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{value > 0 ? '+' : ''}{value} {stat.replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchase Button */}
|
||||
<Button
|
||||
variant={isAffordable && !isDisabled ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'w-full',
|
||||
isAffordable && !isDisabled && 'bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600'
|
||||
)}
|
||||
onClick={() => onPurchaseClick(item)}
|
||||
disabled={!isAffordable || isDisabled}
|
||||
>
|
||||
{isDisabled ? (
|
||||
'Coming Soon'
|
||||
) : !isAffordable ? (
|
||||
'Not Enough Coins'
|
||||
) : (
|
||||
'Purchase'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ import {
|
||||
type BlobbonautProfile,
|
||||
} from '@/lib/blobbi';
|
||||
|
||||
import { ShopModal } from '@/components/shop/ShopModal';
|
||||
import { InventoryModal } from '@/components/shop/InventoryModal';
|
||||
import { BlobbiShopModal } from '@/blobbi/shop/components/BlobbiShopModal';
|
||||
import { BlobbiInventoryModal } from '@/blobbi/shop/components/BlobbiInventoryModal';
|
||||
|
||||
/**
|
||||
* Get the localStorage key for the selected Blobbi.
|
||||
@@ -726,14 +726,14 @@ function BlobbiDashboard({
|
||||
/>
|
||||
|
||||
{/* Shop Modal */}
|
||||
<ShopModal
|
||||
<BlobbiShopModal
|
||||
open={showShopModal}
|
||||
onOpenChange={setShowShopModal}
|
||||
profile={profile}
|
||||
/>
|
||||
|
||||
{/* Inventory Modal */}
|
||||
<InventoryModal
|
||||
<BlobbiInventoryModal
|
||||
open={showInventoryModal}
|
||||
onOpenChange={setShowInventoryModal}
|
||||
profile={profile}
|
||||
|
||||
Reference in New Issue
Block a user