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:
@@ -10,7 +10,9 @@ import {
|
|||||||
saveAvatarCustomization,
|
saveAvatarCustomization,
|
||||||
type AvatarCustomization,
|
type AvatarCustomization,
|
||||||
} from "@/lib/avatar-customization-storage";
|
} from "@/lib/avatar-customization-storage";
|
||||||
import { getOwnedItemIds } from "@/lib/shop-storage";
|
import { normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
|
||||||
|
import type { ShopItemRecord } from "@/lib/shop-catalog";
|
||||||
|
import { getShopInventory } from "@/lib/shop-storage";
|
||||||
|
|
||||||
export function CustomizePageContent() {
|
export function CustomizePageContent() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -20,18 +22,30 @@ export function CustomizePageContent() {
|
|||||||
const [avatarName, setAvatarName] = useState("");
|
const [avatarName, setAvatarName] = useState("");
|
||||||
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
|
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
|
||||||
const [ownedVariantIds, setOwnedVariantIds] = useState<string[]>([]);
|
const [ownedVariantIds, setOwnedVariantIds] = useState<string[]>([]);
|
||||||
|
const [equippedItems, setEquippedItems] = useState<string[]>([]);
|
||||||
|
const [shopItems, setShopItems] = useState<ShopItemRecord[]>([]);
|
||||||
|
const [coins, setCoins] = useState<number | null>(null);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadCustomization = useCallback(async () => {
|
const loadCustomization = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
const [next, owned] = await Promise.all([
|
const [next, inventory] = await Promise.all([
|
||||||
getAvatarCustomization(),
|
getAvatarCustomization(),
|
||||||
getOwnedItemIds(),
|
getShopInventory(),
|
||||||
]);
|
]);
|
||||||
setCustomization(next);
|
setCustomization({
|
||||||
|
...next,
|
||||||
|
equippedItems: inventory.equippedItems,
|
||||||
|
roomBackground: normalizeRoomBackgroundId(
|
||||||
|
inventory.equippedRoomBackground,
|
||||||
|
),
|
||||||
|
});
|
||||||
setAvatarName(next.name);
|
setAvatarName(next.name);
|
||||||
setOwnedVariantIds(owned);
|
setOwnedVariantIds(inventory.ownedItemIds);
|
||||||
|
setEquippedItems(inventory.equippedItems);
|
||||||
|
setShopItems(inventory.items);
|
||||||
|
setCoins(inventory.coins);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoadError(
|
setLoadError(
|
||||||
error instanceof Error ? error.message : "Could not load your avatar.",
|
error instanceof Error ? error.message : "Could not load your avatar.",
|
||||||
@@ -55,7 +69,6 @@ export function CustomizePageContent() {
|
|||||||
await saveAvatarCustomization({
|
await saveAvatarCustomization({
|
||||||
...nextCustomization,
|
...nextCustomization,
|
||||||
name: avatarName.trim() || nextCustomization.name,
|
name: avatarName.trim() || nextCustomization.name,
|
||||||
equippedItems: customization?.equippedItems ?? [],
|
|
||||||
});
|
});
|
||||||
toast("Avatar updated.", "success");
|
toast("Avatar updated.", "success");
|
||||||
await loadCustomization();
|
await loadCustomization();
|
||||||
@@ -80,7 +93,7 @@ export function CustomizePageContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CharacterCreator
|
<CharacterCreator
|
||||||
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${customization.roomBackground}-${avatarName}`}
|
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${customization.roomBackground}-${avatarName}-${ownedVariantIds.join(",")}`}
|
||||||
initialCustomization={{
|
initialCustomization={{
|
||||||
...customization,
|
...customization,
|
||||||
name: avatarName,
|
name: avatarName,
|
||||||
@@ -88,6 +101,10 @@ export function CustomizePageContent() {
|
|||||||
name={avatarName}
|
name={avatarName}
|
||||||
onNameChange={setAvatarName}
|
onNameChange={setAvatarName}
|
||||||
ownedVariantIds={ownedVariantIds}
|
ownedVariantIds={ownedVariantIds}
|
||||||
|
shopItems={shopItems}
|
||||||
|
coins={coins}
|
||||||
|
equippedItems={equippedItems}
|
||||||
|
onShopDataChange={loadCustomization}
|
||||||
showNameField
|
showNameField
|
||||||
saveLabel={isSavingAvatar ? "Saving..." : "Save changes"}
|
saveLabel={isSavingAvatar ? "Saving..." : "Save changes"}
|
||||||
isSaving={isSavingAvatar}
|
isSaving={isSavingAvatar}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import { CharacterColorPresets } from "@/components/character/character-color-presets";
|
import { CharacterColorPresets } from "@/components/character/character-color-presets";
|
||||||
import { CharacterColorSliders } from "@/components/character/character-color-sliders";
|
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 { CharacterPieceSelector } from "@/components/character/character-piece-selector";
|
||||||
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
|
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
|
||||||
import { RoomStyleSelector } from "@/components/pet/room-style-selector";
|
import { RoomStyleSelector } from "@/components/pet/room-style-selector";
|
||||||
|
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useToast } from "@/components/ui/toast-provider";
|
||||||
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||||
import { clampHsl } from "@/lib/character/color-utils";
|
import { clampHsl } from "@/lib/character/color-utils";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +33,17 @@ import {
|
|||||||
normalizeRoomBackgroundId,
|
normalizeRoomBackgroundId,
|
||||||
type RoomBackgroundId,
|
type RoomBackgroundId,
|
||||||
} from "@/lib/room-backgrounds";
|
} 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 }) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -48,6 +61,10 @@ type CharacterCreatorProps = {
|
|||||||
saveLabel?: string;
|
saveLabel?: string;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
ownedVariantIds?: string[];
|
ownedVariantIds?: string[];
|
||||||
|
shopItems?: ShopItemRecord[];
|
||||||
|
coins?: number | null;
|
||||||
|
equippedItems?: string[];
|
||||||
|
onShopDataChange?: () => void | Promise<void>;
|
||||||
onSave?: (customization: AvatarCustomization) => Promise<void>;
|
onSave?: (customization: AvatarCustomization) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,11 +73,16 @@ export function CharacterCreator({
|
|||||||
name: controlledName,
|
name: controlledName,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
ownedVariantIds = [],
|
ownedVariantIds = [],
|
||||||
|
shopItems = [],
|
||||||
|
coins = null,
|
||||||
|
equippedItems: initialEquippedItems = [],
|
||||||
|
onShopDataChange,
|
||||||
showNameField = false,
|
showNameField = false,
|
||||||
saveLabel = "Save avatar",
|
saveLabel = "Save avatar",
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: CharacterCreatorProps) {
|
}: CharacterCreatorProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
const [internalName, setInternalName] = useState(
|
const [internalName, setInternalName] = useState(
|
||||||
initialCustomization?.name ?? "Pixel Me",
|
initialCustomization?.name ?? "Pixel Me",
|
||||||
);
|
);
|
||||||
@@ -83,11 +105,16 @@ export function CharacterCreator({
|
|||||||
const [roomBackground, setRoomBackground] = useState<RoomBackgroundId>(
|
const [roomBackground, setRoomBackground] = useState<RoomBackgroundId>(
|
||||||
normalizeRoomBackgroundId(initialCustomization?.roomBackground),
|
normalizeRoomBackgroundId(initialCustomization?.roomBackground),
|
||||||
);
|
);
|
||||||
|
const [equippedItems, setEquippedItems] = useState<string[]>(
|
||||||
|
initialCustomization?.equippedItems ?? initialEquippedItems,
|
||||||
|
);
|
||||||
const [activePresetId, setActivePresetId] = useState<string>(
|
const [activePresetId, setActivePresetId] = useState<string>(
|
||||||
DEFAULT_GRAY_COLOR.id,
|
DEFAULT_GRAY_COLOR.id,
|
||||||
);
|
);
|
||||||
const [activeTabId, setActiveTabId] = useState<CharacterCreatorTabId>("skin");
|
const [activeTabId, setActiveTabId] = useState<CharacterCreatorTabId>("skin");
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
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 isRoomTab = activeTabId === "room";
|
||||||
const isSkinTab = activeTabId === "skin";
|
const isSkinTab = activeTabId === "skin";
|
||||||
@@ -160,15 +187,135 @@ export function CharacterCreator({
|
|||||||
setRoomBackground(roomId);
|
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 => ({
|
const buildCustomization = (): AvatarCustomization => ({
|
||||||
name,
|
name,
|
||||||
colors,
|
colors,
|
||||||
variants,
|
variants,
|
||||||
customized: true,
|
customized: true,
|
||||||
equippedItems: initialCustomization?.equippedItems ?? [],
|
equippedItems,
|
||||||
roomBackground,
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!onSave) {
|
if (!onSave) {
|
||||||
return;
|
return;
|
||||||
@@ -186,7 +333,26 @@ export function CharacterCreator({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 ? (
|
{showNameField ? (
|
||||||
<div className="mb-4 grid max-w-md gap-2 border-b-2 border-border/60 pb-4">
|
<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>
|
<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="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="flex min-h-full min-w-0 flex-col">
|
||||||
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
<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">
|
<div className="relative z-10 flex h-full items-center justify-center">
|
||||||
<CharacterLayerPreview
|
<CharacterLayerPreview
|
||||||
colors={colors}
|
colors={displayCustomization.colors}
|
||||||
variants={variants}
|
variants={displayCustomization.variants}
|
||||||
|
equippedItems={displayCustomization.equippedItems}
|
||||||
scale={8}
|
scale={8}
|
||||||
compact
|
compact
|
||||||
className="!h-auto max-h-none"
|
className="!h-auto max-h-none"
|
||||||
@@ -226,18 +396,20 @@ export function CharacterCreator({
|
|||||||
<div className="min-h-14">
|
<div className="min-h-14">
|
||||||
{isRoomTab ? (
|
{isRoomTab ? (
|
||||||
<RoomStyleSelector
|
<RoomStyleSelector
|
||||||
activeId={roomBackground}
|
activeId={activeRoomId}
|
||||||
ownedItemIds={ownedVariantIds}
|
ownedItemIds={ownedVariantIds}
|
||||||
onSelect={handleRoomChange}
|
onSelect={handleRoomChange}
|
||||||
|
onLockedSelect={openLockedItemPreview}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CharacterPieceSelector
|
<CharacterPieceSelector
|
||||||
variants={pieceVariants}
|
variants={pieceVariants}
|
||||||
lockedVariantIds={lockedVariantIds}
|
lockedVariantIds={lockedVariantIds}
|
||||||
activeId={variants[activeLayerId]}
|
activeId={activePieceId}
|
||||||
color={activeColor}
|
color={activeColor}
|
||||||
skinColor={colors.skin}
|
skinColor={colors.skin}
|
||||||
onSelect={handleVariantChange}
|
onSelect={handleVariantChange}
|
||||||
|
onLockedSelect={openLockedItemPreview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,5 +457,6 @@ export function CharacterCreator({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type CharacterPieceSelectorProps = {
|
|||||||
color: HSL;
|
color: HSL;
|
||||||
skinColor?: HSL;
|
skinColor?: HSL;
|
||||||
onSelect: (variantId: string) => void;
|
onSelect: (variantId: string) => void;
|
||||||
|
onLockedSelect?: (variantId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CharacterPieceSelector({
|
export function CharacterPieceSelector({
|
||||||
@@ -25,6 +26,7 @@ export function CharacterPieceSelector({
|
|||||||
color,
|
color,
|
||||||
skinColor,
|
skinColor,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onLockedSelect,
|
||||||
}: CharacterPieceSelectorProps) {
|
}: CharacterPieceSelectorProps) {
|
||||||
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
|
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
|
||||||
variants[index] ?? null,
|
variants[index] ?? null,
|
||||||
@@ -74,20 +76,25 @@ export function CharacterPieceSelector({
|
|||||||
<button
|
<button
|
||||||
key={variant.id}
|
key={variant.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={locked}
|
|
||||||
aria-disabled={locked}
|
|
||||||
title={
|
title={
|
||||||
locked ? `${variant.label} — unlock in the shop` : variant.label
|
locked ? `${variant.label} — preview in shop` : variant.label
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
locked ? `${variant.label}, locked — unlock in the shop` : variant.label
|
locked ? `${variant.label}, locked — preview to unlock` : variant.label
|
||||||
}
|
}
|
||||||
aria-pressed={activeId === variant.id}
|
aria-pressed={activeId === variant.id}
|
||||||
onClick={() => onSelect(variant.id)}
|
onClick={() => {
|
||||||
|
if (locked) {
|
||||||
|
onLockedSelect?.(variant.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(variant.id);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex size-14 items-center justify-center rounded-lg border bg-zinc-950/80 p-1.5 transition-colors",
|
"relative flex size-14 items-center justify-center rounded-lg border bg-zinc-950/80 p-1.5 transition-colors",
|
||||||
locked
|
locked
|
||||||
? "cursor-not-allowed border-border/40 opacity-80"
|
? "border-border/40 opacity-80 hover:border-border hover:bg-zinc-900/60"
|
||||||
: activeId === variant.id
|
: activeId === variant.id
|
||||||
? "border-foreground bg-zinc-900 shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
|
? "border-foreground bg-zinc-900 shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
|
||||||
: "border-border/60 hover:border-border hover:bg-zinc-900/60",
|
: "border-border/60 hover:border-border hover:bg-zinc-900/60",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type RoomBackgroundPickerProps = {
|
|||||||
value: RoomBackgroundId;
|
value: RoomBackgroundId;
|
||||||
ownedItemIds: string[];
|
ownedItemIds: string[];
|
||||||
onChange: (roomId: RoomBackgroundId) => void;
|
onChange: (roomId: RoomBackgroundId) => void;
|
||||||
|
onLockedSelect?: (roomId: RoomBackgroundId) => void;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export function RoomBackgroundPicker({
|
|||||||
value,
|
value,
|
||||||
ownedItemIds,
|
ownedItemIds,
|
||||||
onChange,
|
onChange,
|
||||||
|
onLockedSelect,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: RoomBackgroundPickerProps) {
|
}: RoomBackgroundPickerProps) {
|
||||||
return (
|
return (
|
||||||
@@ -47,14 +49,20 @@ export function RoomBackgroundPicker({
|
|||||||
<button
|
<button
|
||||||
key={room.id}
|
key={room.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!unlocked}
|
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
title={
|
title={
|
||||||
unlocked
|
unlocked
|
||||||
? room.name
|
? room.name
|
||||||
: `${room.name} — unlock in the shop`
|
: `${room.name} — preview in shop`
|
||||||
}
|
}
|
||||||
onClick={() => onChange(room.id)}
|
onClick={() => {
|
||||||
|
if (!unlocked) {
|
||||||
|
onLockedSelect?.(room.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(room.id);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative aspect-[4/3] overflow-hidden border-2 transition-transform",
|
"relative aspect-[4/3] overflow-hidden border-2 transition-transform",
|
||||||
selected
|
selected
|
||||||
@@ -62,7 +70,7 @@ export function RoomBackgroundPicker({
|
|||||||
: "border-border/70",
|
: "border-border/70",
|
||||||
unlocked
|
unlocked
|
||||||
? "hover:-translate-y-0.5"
|
? "hover:-translate-y-0.5"
|
||||||
: "cursor-not-allowed opacity-80",
|
: "opacity-80 hover:-translate-y-0.5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("absolute inset-0", room.previewClassName)} />
|
<div className={cn("absolute inset-0", room.previewClassName)} />
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ type RoomStyleSelectorProps = {
|
|||||||
activeId: RoomBackgroundId;
|
activeId: RoomBackgroundId;
|
||||||
ownedItemIds: readonly string[];
|
ownedItemIds: readonly string[];
|
||||||
onSelect: (roomId: RoomBackgroundId) => void;
|
onSelect: (roomId: RoomBackgroundId) => void;
|
||||||
|
onLockedSelect?: (roomId: RoomBackgroundId) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoomStyleSelector({
|
export function RoomStyleSelector({
|
||||||
activeId,
|
activeId,
|
||||||
ownedItemIds,
|
ownedItemIds,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onLockedSelect,
|
||||||
}: RoomStyleSelectorProps) {
|
}: RoomStyleSelectorProps) {
|
||||||
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
|
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
|
||||||
ROOM_BACKGROUNDS[index] ?? null,
|
ROOM_BACKGROUNDS[index] ?? null,
|
||||||
@@ -44,20 +46,25 @@ export function RoomStyleSelector({
|
|||||||
<button
|
<button
|
||||||
key={room.id}
|
key={room.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={locked}
|
|
||||||
aria-disabled={locked}
|
|
||||||
title={
|
title={
|
||||||
locked ? `${room.name} — unlock in the shop` : room.name
|
locked ? `${room.name} — preview in shop` : room.name
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
locked ? `${room.name}, locked — unlock in the shop` : room.name
|
locked ? `${room.name}, locked — preview to unlock` : room.name
|
||||||
}
|
}
|
||||||
aria-pressed={activeId === room.id}
|
aria-pressed={activeId === room.id}
|
||||||
onClick={() => onSelect(room.id)}
|
onClick={() => {
|
||||||
|
if (locked) {
|
||||||
|
onLockedSelect?.(room.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(room.id);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative size-14 overflow-hidden rounded-lg border transition-colors",
|
"relative size-14 overflow-hidden rounded-lg border transition-colors",
|
||||||
locked
|
locked
|
||||||
? "cursor-not-allowed border-border/40 opacity-80"
|
? "border-border/40 opacity-80 hover:border-border"
|
||||||
: activeId === room.id
|
: activeId === room.id
|
||||||
? "border-foreground shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
|
? "border-foreground shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
|
||||||
: "border-border/60 hover:border-border",
|
: "border-border/60 hover:border-border",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
|
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
getShopPreviewRoomId,
|
getShopPreviewRoomId,
|
||||||
} from "@/lib/shop-preview";
|
} from "@/lib/shop-preview";
|
||||||
import type { ShopItemRecord } from "@/lib/shop-catalog";
|
import type { ShopItemRecord } from "@/lib/shop-catalog";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
type ShopItemPreviewModalProps = {
|
type ShopItemPreviewModalProps = {
|
||||||
item: ShopItemRecord | null;
|
item: ShopItemRecord | null;
|
||||||
@@ -24,6 +26,7 @@ type ShopItemPreviewModalProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPurchase: () => void;
|
onPurchase: () => void;
|
||||||
onEquipToggle: () => void;
|
onEquipToggle: () => void;
|
||||||
|
showCustomizeLink?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ShopItemPreviewModal({
|
export function ShopItemPreviewModal({
|
||||||
@@ -37,6 +40,7 @@ export function ShopItemPreviewModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onPurchase,
|
onPurchase,
|
||||||
onEquipToggle,
|
onEquipToggle,
|
||||||
|
showCustomizeLink = true,
|
||||||
}: ShopItemPreviewModalProps) {
|
}: ShopItemPreviewModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -162,6 +166,12 @@ export function ShopItemPreviewModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCustomizeLink ? (
|
||||||
|
<Button type="button" className="w-full" variant="outline" asChild>
|
||||||
|
<Link href={routes.customize}>Open in Customize</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,3 +94,10 @@ function getDefaultPriceForLayer(layerId: ShopLayerId, variantId: string) {
|
|||||||
export function getLayerLabel(layerId: ShopLayerId) {
|
export function getLayerLabel(layerId: ShopLayerId) {
|
||||||
return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId;
|
return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findShopItemById(
|
||||||
|
items: readonly ShopItemRecord[],
|
||||||
|
itemId: string,
|
||||||
|
): ShopItemRecord | undefined {
|
||||||
|
return items.find((item) => item.id === itemId);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user