Files
HaBit/components/shop/shop-content.tsx
Harkamal Randhawa bd369825c3 Tint shop item thumbnails with the user's character layer colors.
Replace default gray previews so clothing and accessories in the shop grid reflect each layer's saved color, including skin tone for eyes.
2026-05-24 09:47:48 -06:00

404 lines
12 KiB
TypeScript

"use client";
import { Coins } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TintedSpriteIcon } from "@/components/character/tinted-sprite-icon";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/components/ui/toast-provider";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import {
defaultAvatarCustomization,
getAvatarCustomization,
type AvatarCustomization,
} from "@/lib/avatar-customization-storage";
import { getRoomBackground, normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
import {
equipShopItem,
getShopInventory,
purchaseShopItem,
unequipShopItem,
} from "@/lib/shop-storage";
import {
getLayerLabel,
type ShopItemRecord,
type ShopLayerId,
} from "@/lib/shop-catalog";
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
import { getShopItemThumbnailColors } from "@/lib/shop-preview";
import { cn } from "@/lib/utils";
function ShopItemPriceBadge({
price,
owned,
equipped,
}: {
price: number;
owned: boolean;
equipped?: boolean;
}) {
const boxClassName =
"inline-flex shrink-0 items-center justify-center gap-2.5 border-2 px-5 font-pixel text-[13px] font-normal uppercase tracking-wide leading-none shadow-[var(--retro-shadow-sm)] h-12";
if (owned) {
return (
<div
className={cn(
boxClassName,
"border-border/60 bg-muted/30 text-muted-foreground",
)}
>
{equipped ? "Equipped" : "Owned"}
</div>
);
}
return (
<div
className={cn(
boxClassName,
"border-primary/35 bg-primary/10 text-primary",
)}
>
<Coins aria-hidden="true" className="size-5 shrink-0" />
<span className="tabular-nums">{price}</span>
<span className="text-primary/80">pts</span>
</div>
);
}
const SHOP_LAYER_ORDER: ShopLayerId[] = [
"head",
"torso",
"pants",
"shoes",
"eyes",
];
function ShopItemCard({
item,
owned,
equipped,
canAfford,
isPending,
characterColors,
onPurchase,
onEquipToggle,
onPreview,
}: {
item: ShopItemRecord;
owned: boolean;
equipped: boolean;
canAfford: boolean;
isPending: boolean;
characterColors: AvatarCustomization["colors"];
onPurchase: () => void;
onEquipToggle: () => void;
onPreview: () => void;
}) {
const room = item.type === "room" ? getRoomBackground(item.id) : null;
const thumbnailColors =
item.type === "room"
? null
: getShopItemThumbnailColors(item, characterColors);
const actionLabel = (() => {
if (isPending) {
return owned ? "Updating..." : "Processing...";
}
if (owned) {
return equipped ? "Equipped" : "Equip";
}
if (!canAfford) {
return "Need more points";
}
return "Buy";
})();
return (
<Card className="flex h-full flex-col overflow-hidden">
<CardContent className="flex h-full flex-col gap-3 p-3">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden border-2 border-border bg-zinc-950/80 p-1">
{room ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.image_path || room.sceneImage}
alt=""
className="size-full object-cover image-pixelated"
/>
) : thumbnailColors ? (
<TintedSpriteIcon
src={item.image_path}
color={thumbnailColors.color}
skinColor={thumbnailColors.skinColor}
size={32}
/>
) : null}
</div>
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
<p className="min-w-0 pt-1 text-sm font-medium leading-snug">
{item.name}
</p>
<ShopItemPriceBadge
price={item.price}
owned={owned}
equipped={equipped}
/>
</div>
</div>
<div className="mt-auto grid grid-cols-2 gap-2">
<Button
type="button"
className="h-auto min-h-9 whitespace-normal px-2 py-2 text-[10px] leading-snug"
variant="outline"
disabled={isPending}
onClick={onPreview}
>
Preview
</Button>
<Button
className="h-auto min-h-9 whitespace-normal px-2 py-2 text-[10px] leading-snug"
variant={owned && equipped ? "default" : "outline"}
disabled={
isPending ||
(!owned && !canAfford) ||
(owned && item.type === "room" && equipped)
}
onClick={owned ? onEquipToggle : onPurchase}
>
{actionLabel}
</Button>
</div>
</CardContent>
</Card>
);
}
export function ShopContent() {
const { toast } = useToast();
const [items, setItems] = useState<ShopItemRecord[]>([]);
const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]);
const [equippedItems, setEquippedItems] = useState<string[]>([]);
const [equippedRoomBackground, setEquippedRoomBackground] = useState("room-1");
const [coins, setCoins] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
const [baseCustomization, setBaseCustomization] =
useState<AvatarCustomization>(defaultAvatarCustomization);
const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null);
const refreshShop = useCallback(async () => {
setError(null);
let nextEquippedItems: string[] = [];
let nextEquippedRoomBackground = "room-1";
try {
const inventory = await getShopInventory();
setItems(inventory.items);
setOwnedItemIds(inventory.ownedItemIds);
setEquippedItems(inventory.equippedItems);
setEquippedRoomBackground(inventory.equippedRoomBackground);
setCoins(inventory.coins);
nextEquippedItems = inventory.equippedItems;
nextEquippedRoomBackground = inventory.equippedRoomBackground;
} catch (refreshError) {
setError(
refreshError instanceof Error
? refreshError.message
: "Could not load the shop.",
);
setCoins(0);
}
try {
const customization = await getAvatarCustomization();
setBaseCustomization({
...customization,
equippedItems: nextEquippedItems,
roomBackground: normalizeRoomBackgroundId(nextEquippedRoomBackground),
});
} catch {
setBaseCustomization((current) => ({
...current,
equippedItems: nextEquippedItems,
roomBackground: normalizeRoomBackgroundId(nextEquippedRoomBackground),
}));
}
}, []);
useEffect(() => {
void refreshShop();
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshShop);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshShop);
};
}, [refreshShop]);
const handlePurchase = async (itemId: string) => {
setPendingItemId(itemId);
try {
const item = await purchaseShopItem(itemId);
await equipShopItem(itemId);
toast(`Purchased and equipped ${item.name}.`, "success");
await refreshShop();
} catch (purchaseError) {
toast(
purchaseError instanceof Error
? purchaseError.message
: "Purchase failed.",
"error",
);
} finally {
setPendingItemId(null);
}
};
const handleEquipToggle = async (item: ShopItemRecord) => {
setPendingItemId(item.id);
try {
const isEquipped =
item.type === "room"
? equippedRoomBackground === item.id
: equippedItems.includes(item.id);
if (item.type !== "room" && isEquipped) {
await unequipShopItem(item.id);
toast(`Unequipped ${item.name}.`, "default");
} else if (!isEquipped) {
await equipShopItem(item.id);
toast(`Equipped ${item.name}.`, "success");
}
await refreshShop();
} catch (equipError) {
toast(
equipError instanceof Error ? equipError.message : "Could not equip item.",
"error",
);
} finally {
setPendingItemId(null);
}
};
const styleGroups = SHOP_LAYER_ORDER.map((layerId) => ({
layerId,
label: getLayerLabel(layerId),
items: items.filter((item) => item.type === layerId),
})).filter((group) => group.items.length > 0);
const roomItems = items.filter((item) => item.type === "room");
const itemGridClassName = "grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3";
const previewOwned = previewItem
? ownedItemIds.includes(previewItem.id)
: false;
const previewEquipped = previewItem
? previewItem.type === "room"
? equippedRoomBackground === previewItem.id
: equippedItems.includes(previewItem.id)
: false;
const previewCanAfford = previewItem
? coins !== null && coins >= previewItem.price
: false;
return (
<>
<ShopItemPreviewModal
item={previewItem}
baseCustomization={baseCustomization}
coins={coins}
owned={previewOwned}
equipped={previewEquipped}
canAfford={previewCanAfford}
isPending={previewItem !== null && pendingItemId === previewItem.id}
onClose={() => setPreviewItem(null)}
onPurchase={() => {
if (previewItem) {
void handlePurchase(previewItem.id);
}
}}
onEquipToggle={() => {
if (previewItem) {
void handleEquipToggle(previewItem);
}
}}
/>
{error ? <p className="mb-4 text-xs text-red-500">{error}</p> : null}
{!error && coins !== null && items.length === 0 ? (
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">
Nothing in the shop right now. Keep completing habits and daily
check-ins to earn points for when new styles arrive.
</p>
</CardContent>
</Card>
) : null}
<div className="grid gap-8">
{roomItems.length > 0 ? (
<section className="grid gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Room backgrounds
</h3>
<div className={itemGridClassName}>
{roomItems.map((item) => (
<ShopItemCard
key={item.id}
item={item}
owned={ownedItemIds.includes(item.id)}
equipped={equippedRoomBackground === item.id}
canAfford={coins !== null && coins >= item.price}
isPending={pendingItemId === item.id}
characterColors={baseCustomization.colors}
onPurchase={() => void handlePurchase(item.id)}
onEquipToggle={() => void handleEquipToggle(item)}
onPreview={() => setPreviewItem(item)}
/>
))}
</div>
</section>
) : null}
{styleGroups.map((group) => (
<section key={group.layerId} className="grid gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</h3>
<div className={itemGridClassName}>
{group.items.map((item) => (
<ShopItemCard
key={item.id}
item={item}
owned={ownedItemIds.includes(item.id)}
equipped={equippedItems.includes(item.id)}
canAfford={coins !== null && coins >= item.price}
isPending={pendingItemId === item.id}
characterColors={baseCustomization.colors}
onPurchase={() => void handlePurchase(item.id)}
onEquipToggle={() => void handleEquipToggle(item)}
onPreview={() => setPreviewItem(item)}
/>
))}
</div>
</section>
))}
</div>
</>
);
}