Replace default gray previews so clothing and accessories in the shop grid reflect each layer's saved color, including skin tone for eyes.
404 lines
12 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|