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:
2026-05-24 04:51:01 -06:00
parent 69131ac8eb
commit 5dda46cb87
12 changed files with 286 additions and 49 deletions

View File

@@ -3,7 +3,6 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { HabitBrand } from "@/components/layout/habit-brand"; import { HabitBrand } from "@/components/layout/habit-brand";
import { HomeLogoLink } from "@/components/layout/home-logo-link";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -26,12 +25,8 @@ export function AppTopBar({ className }: AppTopBarProps) {
paddingTop: "env(safe-area-inset-top, 0px)", paddingTop: "env(safe-area-inset-top, 0px)",
}} }}
> >
<div className="mx-auto flex h-full max-w-6xl items-center justify-between px-4"> <div className="mx-auto flex h-full max-w-6xl items-center px-4">
<HomeLogoLink <HabitBrand aria-current={isHome ? "page" : undefined} />
className="mt-0"
aria-current={isHome ? "page" : undefined}
/>
<HabitBrand />
</div> </div>
</header> </header>
); );

View File

@@ -1,25 +1,35 @@
"use client"; "use client";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { routes } from "@/lib/routes";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const HABIT_LOGO_SRC = "/habit-logo.png"; const HABIT_LOGO_SRC = "/habit-logo.png";
type HabitBrandProps = { type HabitBrandProps = {
className?: string; 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); const [useText, setUseText] = useState(false);
return ( return (
<div <Link
href={href}
aria-label="Go to home"
aria-current={ariaCurrent}
className={cn( 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, className,
)} )}
aria-label="HaBit"
> >
{!useText ? ( {!useText ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -37,6 +47,6 @@ export function HabitBrand({ className }: HabitBrandProps) {
HaBit HaBit
</span> </span>
)} )}
</div> </Link>
); );
} }

View File

@@ -10,6 +10,7 @@ import { useToast } from "@/components/ui/toast-provider";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getAvatarCustomization } from "@/lib/avatar-customization-storage"; import { getAvatarCustomization } from "@/lib/avatar-customization-storage";
import type { AvatarCustomization } 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 { DEFAULT_GRAY_COLOR } from "@/lib/character/presets";
import { getRoomBackground, normalizeRoomBackgroundId } from "@/lib/room-backgrounds"; import { getRoomBackground, normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
import { import {
@@ -145,26 +146,24 @@ export function ShopContent() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pendingItemId, setPendingItemId] = useState<string | null>(null); const [pendingItemId, setPendingItemId] = useState<string | null>(null);
const [baseCustomization, setBaseCustomization] = const [baseCustomization, setBaseCustomization] =
useState<AvatarCustomization | null>(null); useState<AvatarCustomization>(defaultAvatarCustomization);
const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null); const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null);
const refreshShop = useCallback(async () => { const refreshShop = useCallback(async () => {
setError(null);
let nextEquippedItems: string[] = [];
let nextEquippedRoomBackground = "room-day";
try { try {
setError(null); const inventory = await getShopInventory();
const [inventory, customization] = await Promise.all([
getShopInventory(),
getAvatarCustomization(),
]);
setItems(inventory.items); setItems(inventory.items);
setOwnedItemIds(inventory.ownedItemIds); setOwnedItemIds(inventory.ownedItemIds);
setEquippedItems(inventory.equippedItems); setEquippedItems(inventory.equippedItems);
setEquippedRoomBackground(inventory.equippedRoomBackground); setEquippedRoomBackground(inventory.equippedRoomBackground);
setCoins(inventory.coins); setCoins(inventory.coins);
setBaseCustomization({ nextEquippedItems = inventory.equippedItems;
...customization, nextEquippedRoomBackground = inventory.equippedRoomBackground;
equippedItems: inventory.equippedItems,
roomBackground: normalizeRoomBackgroundId(inventory.equippedRoomBackground),
});
} catch (refreshError) { } catch (refreshError) {
setError( setError(
refreshError instanceof Error refreshError instanceof Error
@@ -173,6 +172,21 @@ export function ShopContent() {
); );
setCoins(0); setCoins(0);
} }
try {
const customization = await getAvatarCustomization();
setBaseCustomization({
...customization,
equippedItems: nextEquippedItems,
roomBackground: normalizeRoomBackgroundId(nextEquippedRoomBackground),
});
} catch {
setBaseCustomization((current) => ({
...current,
equippedItems: nextEquippedItems,
roomBackground: normalizeRoomBackgroundId(nextEquippedRoomBackground),
}));
}
}, []); }, []);
useEffect(() => { 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 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 ( return (
<> <>
<ShopItemPreviewModal <ShopItemPreviewModal
item={previewItem} item={previewItem}
baseCustomization={baseCustomization} baseCustomization={baseCustomization}
coins={coins}
owned={previewOwned}
equipped={previewEquipped}
canAfford={previewCanAfford}
isPending={previewItem !== null && pendingItemId === previewItem.id}
onClose={() => setPreviewItem(null)} 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)]"> <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)]">

View File

@@ -16,14 +16,28 @@ import {
type ShopItemPreviewModalProps = { type ShopItemPreviewModalProps = {
item: ShopItemRecord | null; item: ShopItemRecord | null;
baseCustomization: AvatarCustomization | null; baseCustomization: AvatarCustomization;
coins: number | null;
owned: boolean;
equipped: boolean;
canAfford: boolean;
isPending: boolean;
onClose: () => void; onClose: () => void;
onPurchase: () => void;
onEquipToggle: () => void;
}; };
export function ShopItemPreviewModal({ export function ShopItemPreviewModal({
item, item,
baseCustomization, baseCustomization,
coins,
owned,
equipped,
canAfford,
isPending,
onClose, onClose,
onPurchase,
onEquipToggle,
}: ShopItemPreviewModalProps) { }: ShopItemPreviewModalProps) {
useEffect(() => { useEffect(() => {
if (!item) { if (!item) {
@@ -42,7 +56,7 @@ export function ShopItemPreviewModal({
}; };
}, [item, onClose]); }, [item, onClose]);
if (!item || !baseCustomization) { if (!item) {
return null; 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 className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
</div> </div>
<p className="mt-3 text-center text-[10px] text-muted-foreground"> <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> </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> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
import { DEFAULT_STARTING_COINS } from "@/lib/coins";
import { notifyHabitPetDataUpdated } from "@/lib/app-events"; import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import { import {
hexToHsl, hexToHsl,
@@ -204,6 +205,7 @@ export async function getAvatarCustomization(): Promise<AvatarCustomization> {
if (!data) { if (!data) {
const { error: insertError } = await supabase.from("avatar_state").insert({ const { error: insertError } = await supabase.from("avatar_state").insert({
user_id: userId, user_id: userId,
coins: DEFAULT_STARTING_COINS,
avatar_name: DEFAULT_AVATAR_NAME, avatar_name: DEFAULT_AVATAR_NAME,
skin_color: DEFAULT_HEX, skin_color: DEFAULT_HEX,
pants_style: NONE_VARIANT_ID, pants_style: NONE_VARIANT_ID,

View File

@@ -1,3 +1,4 @@
import { DEFAULT_STARTING_COINS } from "@/lib/coins";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
async function getAuthenticatedUserId() { async function getAuthenticatedUserId() {
@@ -35,13 +36,13 @@ export async function getCoins(): Promise<number> {
if (!data) { if (!data) {
const { error: insertError } = await supabase const { error: insertError } = await supabase
.from("avatar_state") .from("avatar_state")
.insert({ user_id: userId, coins: 0 }); .insert({ user_id: userId, coins: DEFAULT_STARTING_COINS });
if (insertError) { if (insertError) {
throw new Error(insertError.message); throw new Error(insertError.message);
} }
return 0; return DEFAULT_STARTING_COINS;
} }
return data.coins ?? 0; return data.coins ?? 0;

View File

@@ -59,6 +59,33 @@ function presetFromHex(id: string, name: string, hex: string): ColorPreset {
return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b)); return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b));
} }
export const VARIANT_DISPLAY_NAMES: Record<string, string> = {
"skin-1": "Default Form",
"pants-1": "Slim Fit Jeans",
"pants-2": "Rolled Cuff Shorts",
"pants-3": "Utility Cargo Pants",
"shoes-1": "Canvas Sneakers",
"shoes-2": "Chunky High-Tops",
"shoes-3": "Neon Glow Runners",
"torso-1": "Crew Neck Tee",
"torso-2": "Zip-Up Hoodie",
"torso-3": "Cropped Tank Top",
"torso-4": "Crimson Plate Armor",
"eyes-1": "Round Gentle Eyes",
"eyes-2": "Starry Spark Eyes",
"head-1": "Spiked Mohawk",
"head-2": "Cozy Knit Beanie",
"head-3": "Retro Snapback Cap",
"head-4": "Fiery Side Mohawk",
"head-5": "Crested Warrior Helm",
"head-6": "Flowing Side Locks",
"head-7": "Messy Shag Cut",
};
export function getVariantDisplayName(variantId: string, fallbackLabel = variantId) {
return VARIANT_DISPLAY_NAMES[variantId] ?? fallbackLabel;
}
function layerVariants( function layerVariants(
layerId: Exclude<CharacterLayerId, "skin">, layerId: Exclude<CharacterLayerId, "skin">,
label: string, label: string,
@@ -66,9 +93,10 @@ function layerVariants(
): LayerVariant[] { ): LayerVariant[] {
return Array.from({ length: count }, (_, index) => { return Array.from({ length: count }, (_, index) => {
const number = index + 1; const number = index + 1;
const id = `${layerId}-${number}`;
return { return {
id: `${layerId}-${number}`, id,
label: `${label} ${number}`, label: getVariantDisplayName(id, `${label} ${number}`),
src: `/character/${layerId}/${layerId}-${number}.png`, src: `/character/${layerId}/${layerId}-${number}.png`,
}; };
}); });
@@ -117,7 +145,11 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [
label: "Skin", label: "Skin",
allowVariants: false, allowVariants: false,
variants: [ variants: [
{ id: "skin-1", label: "Skin 1", src: "/character/skin/skin-1.png" }, {
id: "skin-1",
label: getVariantDisplayName("skin-1", "Skin 1"),
src: "/character/skin/skin-1.png",
},
], ],
}, },
{ {

View File

@@ -1,3 +1,5 @@
export const DEFAULT_STARTING_COINS = 100;
export const COINS_PER_TASK_COMPLETE = 50; export const COINS_PER_TASK_COMPLETE = 50;
export const COINS_PER_TASK_UNCHECK = -50; export const COINS_PER_TASK_UNCHECK = -50;

View File

@@ -24,15 +24,15 @@ where id in (
insert into public.shop_items (id, name, type, price, image_path) insert into public.shop_items (id, name, type, price, image_path)
values values
('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), ('pants-3', 'Utility Cargo Pants', 'pants', 30, '/character/pants/pants-3.png'),
('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), ('shoes-3', 'Neon Glow Runners', 'shoes', 30, '/character/shoes/shoes-3.png'),
('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), ('torso-3', 'Cropped Tank Top', 'torso', 35, '/character/torso/torso-3.png'),
('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'), ('torso-4', 'Crimson Plate Armor', 'torso', 40, '/character/torso/torso-4.png'),
('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'), ('head-3', 'Retro Snapback Cap', 'head', 40, '/character/head/head-3.png'),
('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'), ('head-4', 'Fiery Side Mohawk', 'head', 45, '/character/head/head-4.png'),
('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'), ('head-5', 'Crested Warrior Helm', 'head', 50, '/character/head/head-5.png'),
('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'), ('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'),
('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'), ('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'),
('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'), ('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'),
('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg') ('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg')
on conflict (id) do update on conflict (id) do update

View File

@@ -0,0 +1,10 @@
-- Replace generic shop item names with descriptive labels.
update public.shop_items set name = 'Utility Cargo Pants' where id = 'pants-3';
update public.shop_items set name = 'Neon Glow Runners' where id = 'shoes-3';
update public.shop_items set name = 'Cropped Tank Top' where id = 'torso-3';
update public.shop_items set name = 'Crimson Plate Armor' where id = 'torso-4';
update public.shop_items set name = 'Retro Snapback Cap' where id = 'head-3';
update public.shop_items set name = 'Fiery Side Mohawk' where id = 'head-4';
update public.shop_items set name = 'Crested Warrior Helm' where id = 'head-5';
update public.shop_items set name = 'Flowing Side Locks' where id = 'head-6';
update public.shop_items set name = 'Messy Shag Cut' where id = 'head-7';

View File

@@ -0,0 +1,84 @@
-- New users start with 100 free shop points.
alter table public.avatar_state
alter column coins set default 100;
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, display_name)
values (
new.id,
coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
)
on conflict (id) do nothing;
insert into public.avatar_state (user_id, coins)
values (new.id, 100)
on conflict (user_id) do nothing;
return new;
end;
$$;
create or replace function public.purchase_shop_item(p_item_id text)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_user_id uuid := auth.uid();
v_item public.shop_items%rowtype;
v_coins int;
begin
if v_user_id is null then
raise exception 'You must be signed in to purchase items.';
end if;
select * into v_item from public.shop_items where id = p_item_id;
if not found then
raise exception 'That shop item is no longer available.';
end if;
if exists (
select 1 from public.user_items
where user_id = v_user_id and item_id = p_item_id
) then
raise exception 'You already own this item.';
end if;
select coins into v_coins
from public.avatar_state
where user_id = v_user_id
for update;
if not found then
insert into public.avatar_state (user_id, coins) values (v_user_id, 100);
v_coins := 100;
end if;
if v_coins < v_item.price then
raise exception 'Not enough points for this purchase.';
end if;
update public.avatar_state
set coins = v_coins - v_item.price, updated_at = now()
where user_id = v_user_id;
insert into public.user_items (user_id, item_id)
values (v_user_id, p_item_id);
return jsonb_build_object(
'item_id', v_item.id,
'name', v_item.name,
'type', v_item.type,
'price', v_item.price,
'image_path', v_item.image_path,
'coins', v_coins - v_item.price
);
end;
$$;

View File

@@ -24,15 +24,15 @@ where id in (
insert into public.shop_items (id, name, type, price, image_path) insert into public.shop_items (id, name, type, price, image_path)
values values
('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), ('pants-3', 'Utility Cargo Pants', 'pants', 30, '/character/pants/pants-3.png'),
('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), ('shoes-3', 'Neon Glow Runners', 'shoes', 30, '/character/shoes/shoes-3.png'),
('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), ('torso-3', 'Cropped Tank Top', 'torso', 35, '/character/torso/torso-3.png'),
('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'), ('torso-4', 'Crimson Plate Armor', 'torso', 40, '/character/torso/torso-4.png'),
('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'), ('head-3', 'Retro Snapback Cap', 'head', 40, '/character/head/head-3.png'),
('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'), ('head-4', 'Fiery Side Mohawk', 'head', 45, '/character/head/head-4.png'),
('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'), ('head-5', 'Crested Warrior Helm', 'head', 50, '/character/head/head-5.png'),
('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'), ('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'),
('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'), ('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'),
('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'), ('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'),
('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg') ('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg')
on conflict (id) do update on conflict (id) do update