Add customize-screen shop preview and purchase flow.
Locked items can be previewed on the pet from the editor, purchased with points in the preview modal, and opened in the full customize view from the shop.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { CharacterColorPresets } from "@/components/character/character-color-presets";
|
||||
import { CharacterColorSliders } from "@/components/character/character-color-sliders";
|
||||
@@ -9,9 +9,11 @@ import { CharacterLayerTabs } from "@/components/character/character-layer-tabs"
|
||||
import { CharacterPieceSelector } from "@/components/character/character-piece-selector";
|
||||
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
|
||||
import { RoomStyleSelector } from "@/components/pet/room-style-selector";
|
||||
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/components/ui/toast-provider";
|
||||
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import { clampHsl } from "@/lib/character/color-utils";
|
||||
import {
|
||||
@@ -31,6 +33,17 @@ import {
|
||||
normalizeRoomBackgroundId,
|
||||
type RoomBackgroundId,
|
||||
} from "@/lib/room-backgrounds";
|
||||
import {
|
||||
findShopItemById,
|
||||
parseShopStyleItem,
|
||||
type ShopItemRecord,
|
||||
} from "@/lib/shop-catalog";
|
||||
import { buildShopItemPreviewCustomization } from "@/lib/shop-preview";
|
||||
import {
|
||||
equipShopItem,
|
||||
purchaseShopItem,
|
||||
unequipShopItem,
|
||||
} from "@/lib/shop-storage";
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -48,6 +61,10 @@ type CharacterCreatorProps = {
|
||||
saveLabel?: string;
|
||||
isSaving?: boolean;
|
||||
ownedVariantIds?: string[];
|
||||
shopItems?: ShopItemRecord[];
|
||||
coins?: number | null;
|
||||
equippedItems?: string[];
|
||||
onShopDataChange?: () => void | Promise<void>;
|
||||
onSave?: (customization: AvatarCustomization) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -56,11 +73,16 @@ export function CharacterCreator({
|
||||
name: controlledName,
|
||||
onNameChange,
|
||||
ownedVariantIds = [],
|
||||
shopItems = [],
|
||||
coins = null,
|
||||
equippedItems: initialEquippedItems = [],
|
||||
onShopDataChange,
|
||||
showNameField = false,
|
||||
saveLabel = "Save avatar",
|
||||
isSaving = false,
|
||||
onSave,
|
||||
}: CharacterCreatorProps) {
|
||||
const { toast } = useToast();
|
||||
const [internalName, setInternalName] = useState(
|
||||
initialCustomization?.name ?? "Pixel Me",
|
||||
);
|
||||
@@ -83,11 +105,16 @@ export function CharacterCreator({
|
||||
const [roomBackground, setRoomBackground] = useState<RoomBackgroundId>(
|
||||
normalizeRoomBackgroundId(initialCustomization?.roomBackground),
|
||||
);
|
||||
const [equippedItems, setEquippedItems] = useState<string[]>(
|
||||
initialCustomization?.equippedItems ?? initialEquippedItems,
|
||||
);
|
||||
const [activePresetId, setActivePresetId] = useState<string>(
|
||||
DEFAULT_GRAY_COLOR.id,
|
||||
);
|
||||
const [activeTabId, setActiveTabId] = useState<CharacterCreatorTabId>("skin");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null);
|
||||
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
|
||||
|
||||
const isRoomTab = activeTabId === "room";
|
||||
const isSkinTab = activeTabId === "skin";
|
||||
@@ -160,15 +187,135 @@ export function CharacterCreator({
|
||||
setRoomBackground(roomId);
|
||||
};
|
||||
|
||||
const openLockedItemPreview = useCallback(
|
||||
(itemId: string) => {
|
||||
const item = findShopItemById(shopItems, itemId);
|
||||
|
||||
if (!item) {
|
||||
toast("This item is not available in the shop yet.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewItem(item);
|
||||
},
|
||||
[shopItems, toast],
|
||||
);
|
||||
|
||||
const buildCustomization = (): AvatarCustomization => ({
|
||||
name,
|
||||
colors,
|
||||
variants,
|
||||
customized: true,
|
||||
equippedItems: initialCustomization?.equippedItems ?? [],
|
||||
equippedItems,
|
||||
roomBackground,
|
||||
});
|
||||
|
||||
const displayCustomization = previewItem
|
||||
? buildShopItemPreviewCustomization(buildCustomization(), previewItem)
|
||||
: buildCustomization();
|
||||
|
||||
const previewOwned = previewItem
|
||||
? ownedVariantIds.includes(previewItem.id)
|
||||
: false;
|
||||
const previewEquipped = previewItem
|
||||
? previewItem.type === "room"
|
||||
? roomBackground === previewItem.id
|
||||
: equippedItems.includes(previewItem.id)
|
||||
: false;
|
||||
const previewCanAfford = previewItem
|
||||
? coins !== null && coins >= previewItem.price
|
||||
: false;
|
||||
|
||||
const applyOwnedItemToEditor = (item: ShopItemRecord) => {
|
||||
if (item.type === "room") {
|
||||
setRoomBackground(normalizeRoomBackgroundId(item.id));
|
||||
return;
|
||||
}
|
||||
|
||||
const style = parseShopStyleItem(item.id, item.type);
|
||||
|
||||
if (style) {
|
||||
setVariants((current) => ({
|
||||
...current,
|
||||
[style.layerId]: style.variantId,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewPurchase = async () => {
|
||||
if (!previewItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingItemId(previewItem.id);
|
||||
|
||||
try {
|
||||
const purchased = await purchaseShopItem(previewItem.id);
|
||||
const nextEquipped = await equipShopItem(previewItem.id);
|
||||
setEquippedItems(nextEquipped);
|
||||
applyOwnedItemToEditor(purchased);
|
||||
toast(`Purchased and equipped ${purchased.name}.`, "success");
|
||||
setPreviewItem(null);
|
||||
await onShopDataChange?.();
|
||||
} catch (error) {
|
||||
toast(
|
||||
error instanceof Error ? error.message : "Purchase failed.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setPendingItemId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewEquipToggle = async () => {
|
||||
if (!previewItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingItemId(previewItem.id);
|
||||
|
||||
try {
|
||||
const isEquipped =
|
||||
previewItem.type === "room"
|
||||
? roomBackground === previewItem.id
|
||||
: equippedItems.includes(previewItem.id);
|
||||
|
||||
if (previewItem.type !== "room" && isEquipped) {
|
||||
const nextEquipped = await unequipShopItem(previewItem.id);
|
||||
setEquippedItems(nextEquipped);
|
||||
toast(`Unequipped ${previewItem.name}.`, "default");
|
||||
} else if (!isEquipped) {
|
||||
const nextEquipped = await equipShopItem(previewItem.id);
|
||||
setEquippedItems(nextEquipped);
|
||||
applyOwnedItemToEditor(previewItem);
|
||||
toast(`Equipped ${previewItem.name}.`, "success");
|
||||
setPreviewItem(null);
|
||||
}
|
||||
|
||||
await onShopDataChange?.();
|
||||
} catch (error) {
|
||||
toast(
|
||||
error instanceof Error ? error.message : "Could not equip item.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setPendingItemId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const previewStyleLayerId =
|
||||
previewItem && previewItem.type !== "room"
|
||||
? parseShopStyleItem(previewItem.id, previewItem.type)?.layerId
|
||||
: null;
|
||||
const activePieceId =
|
||||
previewStyleLayerId === activeLayerId && previewItem
|
||||
? previewItem.id
|
||||
: variants[activeLayerId];
|
||||
const activeRoomId: RoomBackgroundId =
|
||||
previewItem?.type === "room"
|
||||
? normalizeRoomBackgroundId(previewItem.id)
|
||||
: roomBackground;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) {
|
||||
return;
|
||||
@@ -186,7 +333,26 @@ export function CharacterCreator({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
|
||||
<>
|
||||
<ShopItemPreviewModal
|
||||
item={previewItem}
|
||||
baseCustomization={buildCustomization()}
|
||||
coins={coins}
|
||||
owned={previewOwned}
|
||||
equipped={previewEquipped}
|
||||
canAfford={previewCanAfford}
|
||||
isPending={previewItem !== null && pendingItemId === previewItem.id}
|
||||
showCustomizeLink={false}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
onPurchase={() => {
|
||||
void handlePreviewPurchase();
|
||||
}}
|
||||
onEquipToggle={() => {
|
||||
void handlePreviewEquipToggle();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
|
||||
{showNameField ? (
|
||||
<div className="mb-4 grid max-w-md gap-2 border-b-2 border-border/60 pb-4">
|
||||
<Label htmlFor="avatar-name">Pet name</Label>
|
||||
@@ -205,11 +371,15 @@ export function CharacterCreator({
|
||||
<div className="customize-body mt-4 grid items-stretch gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
|
||||
<div className="flex min-h-full min-w-0 flex-col">
|
||||
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
||||
<ParallaxRoomBackground roomId={roomBackground} interactive={false} />
|
||||
<ParallaxRoomBackground
|
||||
roomId={displayCustomization.roomBackground}
|
||||
interactive={false}
|
||||
/>
|
||||
<div className="relative z-10 flex h-full items-center justify-center">
|
||||
<CharacterLayerPreview
|
||||
colors={colors}
|
||||
variants={variants}
|
||||
colors={displayCustomization.colors}
|
||||
variants={displayCustomization.variants}
|
||||
equippedItems={displayCustomization.equippedItems}
|
||||
scale={8}
|
||||
compact
|
||||
className="!h-auto max-h-none"
|
||||
@@ -226,18 +396,20 @@ export function CharacterCreator({
|
||||
<div className="min-h-14">
|
||||
{isRoomTab ? (
|
||||
<RoomStyleSelector
|
||||
activeId={roomBackground}
|
||||
activeId={activeRoomId}
|
||||
ownedItemIds={ownedVariantIds}
|
||||
onSelect={handleRoomChange}
|
||||
onLockedSelect={openLockedItemPreview}
|
||||
/>
|
||||
) : (
|
||||
<CharacterPieceSelector
|
||||
variants={pieceVariants}
|
||||
lockedVariantIds={lockedVariantIds}
|
||||
activeId={variants[activeLayerId]}
|
||||
activeId={activePieceId}
|
||||
color={activeColor}
|
||||
skinColor={colors.skin}
|
||||
onSelect={handleVariantChange}
|
||||
onLockedSelect={openLockedItemPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,5 +457,6 @@ export function CharacterCreator({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user