Improve shop UX, starting balance, and top bar branding.
Give new users 100 free points, allow shop previews regardless of balance, rename clothing items with descriptive labels, and show only the HaBit logo in the top-left.
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { HabitBrand } from "@/components/layout/habit-brand";
|
||||
import { HomeLogoLink } from "@/components/layout/home-logo-link";
|
||||
import { routes } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -26,12 +25,8 @@ export function AppTopBar({ className }: AppTopBarProps) {
|
||||
paddingTop: "env(safe-area-inset-top, 0px)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-6xl items-center justify-between px-4">
|
||||
<HomeLogoLink
|
||||
className="mt-0"
|
||||
aria-current={isHome ? "page" : undefined}
|
||||
/>
|
||||
<HabitBrand />
|
||||
<div className="mx-auto flex h-full max-w-6xl items-center px-4">
|
||||
<HabitBrand aria-current={isHome ? "page" : undefined} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { routes } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HABIT_LOGO_SRC = "/habit-logo.png";
|
||||
|
||||
type HabitBrandProps = {
|
||||
className?: string;
|
||||
href?: string;
|
||||
"aria-current"?: "page" | undefined;
|
||||
};
|
||||
|
||||
export function HabitBrand({ className }: HabitBrandProps) {
|
||||
export function HabitBrand({
|
||||
className,
|
||||
href = routes.avatar,
|
||||
"aria-current": ariaCurrent,
|
||||
}: HabitBrandProps) {
|
||||
const [useText, setUseText] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
href={href}
|
||||
aria-label="Go to home"
|
||||
aria-current={ariaCurrent}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center border-2 border-border bg-card/80 px-3 py-1.5 shadow-[var(--retro-shadow-sm)]",
|
||||
"group flex shrink-0 items-center justify-center border-2 border-border bg-card/80 px-3 py-1.5 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",
|
||||
className,
|
||||
)}
|
||||
aria-label="HaBit"
|
||||
>
|
||||
{!useText ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -37,6 +47,6 @@ export function HabitBrand({ className }: HabitBrandProps) {
|
||||
HaBit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToast } from "@/components/ui/toast-provider";
|
||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||
import { getAvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import { defaultAvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import { DEFAULT_GRAY_COLOR } from "@/lib/character/presets";
|
||||
import { getRoomBackground, normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
|
||||
import {
|
||||
@@ -145,26 +146,24 @@ export function ShopContent() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
|
||||
const [baseCustomization, setBaseCustomization] =
|
||||
useState<AvatarCustomization | null>(null);
|
||||
useState<AvatarCustomization>(defaultAvatarCustomization);
|
||||
const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null);
|
||||
|
||||
const refreshShop = useCallback(async () => {
|
||||
setError(null);
|
||||
|
||||
let nextEquippedItems: string[] = [];
|
||||
let nextEquippedRoomBackground = "room-day";
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const [inventory, customization] = await Promise.all([
|
||||
getShopInventory(),
|
||||
getAvatarCustomization(),
|
||||
]);
|
||||
const inventory = await getShopInventory();
|
||||
setItems(inventory.items);
|
||||
setOwnedItemIds(inventory.ownedItemIds);
|
||||
setEquippedItems(inventory.equippedItems);
|
||||
setEquippedRoomBackground(inventory.equippedRoomBackground);
|
||||
setCoins(inventory.coins);
|
||||
setBaseCustomization({
|
||||
...customization,
|
||||
equippedItems: inventory.equippedItems,
|
||||
roomBackground: normalizeRoomBackgroundId(inventory.equippedRoomBackground),
|
||||
});
|
||||
nextEquippedItems = inventory.equippedItems;
|
||||
nextEquippedRoomBackground = inventory.equippedRoomBackground;
|
||||
} catch (refreshError) {
|
||||
setError(
|
||||
refreshError instanceof Error
|
||||
@@ -173,6 +172,21 @@ export function ShopContent() {
|
||||
);
|
||||
setCoins(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const customization = await getAvatarCustomization();
|
||||
setBaseCustomization({
|
||||
...customization,
|
||||
equippedItems: nextEquippedItems,
|
||||
roomBackground: normalizeRoomBackgroundId(nextEquippedRoomBackground),
|
||||
});
|
||||
} catch {
|
||||
setBaseCustomization((current) => ({
|
||||
...current,
|
||||
equippedItems: nextEquippedItems,
|
||||
roomBackground: normalizeRoomBackgroundId(nextEquippedRoomBackground),
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -242,12 +256,39 @@ export function ShopContent() {
|
||||
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-5 flex items-center justify-between gap-3 border-2 border-border bg-muted/40 px-4 py-3 shadow-[var(--retro-shadow-sm)]">
|
||||
|
||||
@@ -16,14 +16,28 @@ import {
|
||||
|
||||
type ShopItemPreviewModalProps = {
|
||||
item: ShopItemRecord | null;
|
||||
baseCustomization: AvatarCustomization | null;
|
||||
baseCustomization: AvatarCustomization;
|
||||
coins: number | null;
|
||||
owned: boolean;
|
||||
equipped: boolean;
|
||||
canAfford: boolean;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onPurchase: () => void;
|
||||
onEquipToggle: () => void;
|
||||
};
|
||||
|
||||
export function ShopItemPreviewModal({
|
||||
item,
|
||||
baseCustomization,
|
||||
coins,
|
||||
owned,
|
||||
equipped,
|
||||
canAfford,
|
||||
isPending,
|
||||
onClose,
|
||||
onPurchase,
|
||||
onEquipToggle,
|
||||
}: ShopItemPreviewModalProps) {
|
||||
useEffect(() => {
|
||||
if (!item) {
|
||||
@@ -42,7 +56,7 @@ export function ShopItemPreviewModal({
|
||||
};
|
||||
}, [item, onClose]);
|
||||
|
||||
if (!item || !baseCustomization) {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -104,8 +118,54 @@ export function ShopItemPreviewModal({
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
|
||||
</div>
|
||||
<p className="mt-3 text-center text-[10px] text-muted-foreground">
|
||||
This preview does not equip or purchase the item.
|
||||
Preview only — nothing is purchased or equipped until you confirm.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-2 border-t-2 border-border pt-4">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-muted-foreground">Price</span>
|
||||
<span>{item.price} points</span>
|
||||
</div>
|
||||
|
||||
{owned ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant={equipped ? "default" : "outline"}
|
||||
disabled={
|
||||
isPending || (item.type !== "room" && equipped)
|
||||
}
|
||||
onClick={onEquipToggle}
|
||||
>
|
||||
{isPending
|
||||
? "Updating..."
|
||||
: equipped
|
||||
? "Equipped"
|
||||
: "Equip"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{!canAfford && coins !== null ? (
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
You need {item.price - coins} more points to buy this item.
|
||||
Keep completing habits to earn more.
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={isPending || !canAfford}
|
||||
onClick={onPurchase}
|
||||
>
|
||||
{isPending
|
||||
? "Processing..."
|
||||
: canAfford
|
||||
? `Buy for ${item.price} points`
|
||||
: "Need more points"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user