Skip to content

Commit 3470d44

Browse files
committed
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.
1 parent 5d80b60 commit 3470d44

7 files changed

Lines changed: 260 additions & 31 deletions

File tree

components/avatar/customize-page-content.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
saveAvatarCustomization,
1111
type AvatarCustomization,
1212
} from "@/lib/avatar-customization-storage";
13-
import { getOwnedItemIds } from "@/lib/shop-storage";
13+
import { normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
14+
import type { ShopItemRecord } from "@/lib/shop-catalog";
15+
import { getShopInventory } from "@/lib/shop-storage";
1416

1517
export function CustomizePageContent() {
1618
const { toast } = useToast();
@@ -20,18 +22,30 @@ export function CustomizePageContent() {
2022
const [avatarName, setAvatarName] = useState("");
2123
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
2224
const [ownedVariantIds, setOwnedVariantIds] = useState<string[]>([]);
25+
const [equippedItems, setEquippedItems] = useState<string[]>([]);
26+
const [shopItems, setShopItems] = useState<ShopItemRecord[]>([]);
27+
const [coins, setCoins] = useState<number | null>(null);
2328
const [loadError, setLoadError] = useState<string | null>(null);
2429

2530
const loadCustomization = useCallback(async () => {
2631
try {
2732
setLoadError(null);
28-
const [next, owned] = await Promise.all([
33+
const [next, inventory] = await Promise.all([
2934
getAvatarCustomization(),
30-
getOwnedItemIds(),
35+
getShopInventory(),
3136
]);
32-
setCustomization(next);
37+
setCustomization({
38+
...next,
39+
equippedItems: inventory.equippedItems,
40+
roomBackground: normalizeRoomBackgroundId(
41+
inventory.equippedRoomBackground,
42+
),
43+
});
3344
setAvatarName(next.name);
34-
setOwnedVariantIds(owned);
45+
setOwnedVariantIds(inventory.ownedItemIds);
46+
setEquippedItems(inventory.equippedItems);
47+
setShopItems(inventory.items);
48+
setCoins(inventory.coins);
3549
} catch (error) {
3650
setLoadError(
3751
error instanceof Error ? error.message : "Could not load your avatar.",
@@ -55,7 +69,6 @@ export function CustomizePageContent() {
5569
await saveAvatarCustomization({
5670
...nextCustomization,
5771
name: avatarName.trim() || nextCustomization.name,
58-
equippedItems: customization?.equippedItems ?? [],
5972
});
6073
toast("Avatar updated.", "success");
6174
await loadCustomization();
@@ -80,14 +93,18 @@ export function CustomizePageContent() {
8093

8194
return (
8295
<CharacterCreator
83-
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${customization.roomBackground}-${avatarName}`}
96+
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${customization.roomBackground}-${avatarName}-${ownedVariantIds.join(",")}`}
8497
initialCustomization={{
8598
...customization,
8699
name: avatarName,
87100
}}
88101
name={avatarName}
89102
onNameChange={setAvatarName}
90103
ownedVariantIds={ownedVariantIds}
104+
shopItems={shopItems}
105+
coins={coins}
106+
equippedItems={equippedItems}
107+
onShopDataChange={loadCustomization}
91108
showNameField
92109
saveLabel={isSavingAvatar ? "Saving..." : "Save changes"}
93110
isSaving={isSavingAvatar}

components/character/character-creator.tsx

Lines changed: 181 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useCallback, useState } from "react";
44

55
import { CharacterColorPresets } from "@/components/character/character-color-presets";
66
import { CharacterColorSliders } from "@/components/character/character-color-sliders";
@@ -9,9 +9,11 @@ import { CharacterLayerTabs } from "@/components/character/character-layer-tabs"
99
import { CharacterPieceSelector } from "@/components/character/character-piece-selector";
1010
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
1111
import { RoomStyleSelector } from "@/components/pet/room-style-selector";
12+
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
1213
import { Button } from "@/components/ui/button";
1314
import { Input } from "@/components/ui/input";
1415
import { Label } from "@/components/ui/label";
16+
import { useToast } from "@/components/ui/toast-provider";
1517
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
1618
import { clampHsl } from "@/lib/character/color-utils";
1719
import {
@@ -31,6 +33,17 @@ import {
3133
normalizeRoomBackgroundId,
3234
type RoomBackgroundId,
3335
} from "@/lib/room-backgrounds";
36+
import {
37+
findShopItemById,
38+
parseShopStyleItem,
39+
type ShopItemRecord,
40+
} from "@/lib/shop-catalog";
41+
import { buildShopItemPreviewCustomization } from "@/lib/shop-preview";
42+
import {
43+
equipShopItem,
44+
purchaseShopItem,
45+
unequipShopItem,
46+
} from "@/lib/shop-storage";
3447

3548
function SectionLabel({ children }: { children: React.ReactNode }) {
3649
return (
@@ -48,6 +61,10 @@ type CharacterCreatorProps = {
4861
saveLabel?: string;
4962
isSaving?: boolean;
5063
ownedVariantIds?: string[];
64+
shopItems?: ShopItemRecord[];
65+
coins?: number | null;
66+
equippedItems?: string[];
67+
onShopDataChange?: () => void | Promise<void>;
5168
onSave?: (customization: AvatarCustomization) => Promise<void>;
5269
};
5370

@@ -56,11 +73,16 @@ export function CharacterCreator({
5673
name: controlledName,
5774
onNameChange,
5875
ownedVariantIds = [],
76+
shopItems = [],
77+
coins = null,
78+
equippedItems: initialEquippedItems = [],
79+
onShopDataChange,
5980
showNameField = false,
6081
saveLabel = "Save avatar",
6182
isSaving = false,
6283
onSave,
6384
}: CharacterCreatorProps) {
85+
const { toast } = useToast();
6486
const [internalName, setInternalName] = useState(
6587
initialCustomization?.name ?? "Pixel Me",
6688
);
@@ -83,11 +105,16 @@ export function CharacterCreator({
83105
const [roomBackground, setRoomBackground] = useState<RoomBackgroundId>(
84106
normalizeRoomBackgroundId(initialCustomization?.roomBackground),
85107
);
108+
const [equippedItems, setEquippedItems] = useState<string[]>(
109+
initialCustomization?.equippedItems ?? initialEquippedItems,
110+
);
86111
const [activePresetId, setActivePresetId] = useState<string>(
87112
DEFAULT_GRAY_COLOR.id,
88113
);
89114
const [activeTabId, setActiveTabId] = useState<CharacterCreatorTabId>("skin");
90115
const [saveError, setSaveError] = useState<string | null>(null);
116+
const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null);
117+
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
91118

92119
const isRoomTab = activeTabId === "room";
93120
const isSkinTab = activeTabId === "skin";
@@ -160,15 +187,135 @@ export function CharacterCreator({
160187
setRoomBackground(roomId);
161188
};
162189

190+
const openLockedItemPreview = useCallback(
191+
(itemId: string) => {
192+
const item = findShopItemById(shopItems, itemId);
193+
194+
if (!item) {
195+
toast("This item is not available in the shop yet.", "error");
196+
return;
197+
}
198+
199+
setPreviewItem(item);
200+
},
201+
[shopItems, toast],
202+
);
203+
163204
const buildCustomization = (): AvatarCustomization => ({
164205
name,
165206
colors,
166207
variants,
167208
customized: true,
168-
equippedItems: initialCustomization?.equippedItems ?? [],
209+
equippedItems,
169210
roomBackground,
170211
});
171212

213+
const displayCustomization = previewItem
214+
? buildShopItemPreviewCustomization(buildCustomization(), previewItem)
215+
: buildCustomization();
216+
217+
const previewOwned = previewItem
218+
? ownedVariantIds.includes(previewItem.id)
219+
: false;
220+
const previewEquipped = previewItem
221+
? previewItem.type === "room"
222+
? roomBackground === previewItem.id
223+
: equippedItems.includes(previewItem.id)
224+
: false;
225+
const previewCanAfford = previewItem
226+
? coins !== null && coins >= previewItem.price
227+
: false;
228+
229+
const applyOwnedItemToEditor = (item: ShopItemRecord) => {
230+
if (item.type === "room") {
231+
setRoomBackground(normalizeRoomBackgroundId(item.id));
232+
return;
233+
}
234+
235+
const style = parseShopStyleItem(item.id, item.type);
236+
237+
if (style) {
238+
setVariants((current) => ({
239+
...current,
240+
[style.layerId]: style.variantId,
241+
}));
242+
}
243+
};
244+
245+
const handlePreviewPurchase = async () => {
246+
if (!previewItem) {
247+
return;
248+
}
249+
250+
setPendingItemId(previewItem.id);
251+
252+
try {
253+
const purchased = await purchaseShopItem(previewItem.id);
254+
const nextEquipped = await equipShopItem(previewItem.id);
255+
setEquippedItems(nextEquipped);
256+
applyOwnedItemToEditor(purchased);
257+
toast(`Purchased and equipped ${purchased.name}.`, "success");
258+
setPreviewItem(null);
259+
await onShopDataChange?.();
260+
} catch (error) {
261+
toast(
262+
error instanceof Error ? error.message : "Purchase failed.",
263+
"error",
264+
);
265+
} finally {
266+
setPendingItemId(null);
267+
}
268+
};
269+
270+
const handlePreviewEquipToggle = async () => {
271+
if (!previewItem) {
272+
return;
273+
}
274+
275+
setPendingItemId(previewItem.id);
276+
277+
try {
278+
const isEquipped =
279+
previewItem.type === "room"
280+
? roomBackground === previewItem.id
281+
: equippedItems.includes(previewItem.id);
282+
283+
if (previewItem.type !== "room" && isEquipped) {
284+
const nextEquipped = await unequipShopItem(previewItem.id);
285+
setEquippedItems(nextEquipped);
286+
toast(`Unequipped ${previewItem.name}.`, "default");
287+
} else if (!isEquipped) {
288+
const nextEquipped = await equipShopItem(previewItem.id);
289+
setEquippedItems(nextEquipped);
290+
applyOwnedItemToEditor(previewItem);
291+
toast(`Equipped ${previewItem.name}.`, "success");
292+
setPreviewItem(null);
293+
}
294+
295+
await onShopDataChange?.();
296+
} catch (error) {
297+
toast(
298+
error instanceof Error ? error.message : "Could not equip item.",
299+
"error",
300+
);
301+
} finally {
302+
setPendingItemId(null);
303+
}
304+
};
305+
306+
const previewStyleLayerId =
307+
previewItem && previewItem.type !== "room"
308+
? parseShopStyleItem(previewItem.id, previewItem.type)?.layerId
309+
: null;
310+
const activePieceId =
311+
previewStyleLayerId === activeLayerId && previewItem
312+
? previewItem.id
313+
: variants[activeLayerId];
314+
const activeRoomId: RoomBackgroundId =
315+
previewItem?.type === "room"
316+
? normalizeRoomBackgroundId(previewItem.id)
317+
: roomBackground;
318+
172319
const handleSave = async () => {
173320
if (!onSave) {
174321
return;
@@ -186,7 +333,26 @@ export function CharacterCreator({
186333
};
187334

188335
return (
189-
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
336+
<>
337+
<ShopItemPreviewModal
338+
item={previewItem}
339+
baseCustomization={buildCustomization()}
340+
coins={coins}
341+
owned={previewOwned}
342+
equipped={previewEquipped}
343+
canAfford={previewCanAfford}
344+
isPending={previewItem !== null && pendingItemId === previewItem.id}
345+
showCustomizeLink={false}
346+
onClose={() => setPreviewItem(null)}
347+
onPurchase={() => {
348+
void handlePreviewPurchase();
349+
}}
350+
onEquipToggle={() => {
351+
void handlePreviewEquipToggle();
352+
}}
353+
/>
354+
355+
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
190356
{showNameField ? (
191357
<div className="mb-4 grid max-w-md gap-2 border-b-2 border-border/60 pb-4">
192358
<Label htmlFor="avatar-name">Pet name</Label>
@@ -205,11 +371,15 @@ export function CharacterCreator({
205371
<div className="customize-body mt-4 grid items-stretch gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
206372
<div className="flex min-h-full min-w-0 flex-col">
207373
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
208-
<ParallaxRoomBackground roomId={roomBackground} interactive={false} />
374+
<ParallaxRoomBackground
375+
roomId={displayCustomization.roomBackground}
376+
interactive={false}
377+
/>
209378
<div className="relative z-10 flex h-full items-center justify-center">
210379
<CharacterLayerPreview
211-
colors={colors}
212-
variants={variants}
380+
colors={displayCustomization.colors}
381+
variants={displayCustomization.variants}
382+
equippedItems={displayCustomization.equippedItems}
213383
scale={8}
214384
compact
215385
className="!h-auto max-h-none"
@@ -226,18 +396,20 @@ export function CharacterCreator({
226396
<div className="min-h-14">
227397
{isRoomTab ? (
228398
<RoomStyleSelector
229-
activeId={roomBackground}
399+
activeId={activeRoomId}
230400
ownedItemIds={ownedVariantIds}
231401
onSelect={handleRoomChange}
402+
onLockedSelect={openLockedItemPreview}
232403
/>
233404
) : (
234405
<CharacterPieceSelector
235406
variants={pieceVariants}
236407
lockedVariantIds={lockedVariantIds}
237-
activeId={variants[activeLayerId]}
408+
activeId={activePieceId}
238409
color={activeColor}
239410
skinColor={colors.skin}
240411
onSelect={handleVariantChange}
412+
onLockedSelect={openLockedItemPreview}
241413
/>
242414
)}
243415
</div>
@@ -285,5 +457,6 @@ export function CharacterCreator({
285457
</div>
286458
</div>
287459
</div>
460+
</>
288461
);
289462
}

0 commit comments

Comments
 (0)