From 5dda46cb87955383cf31b14f6e8f65e02bd0b17e Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 24 May 2026 04:51:01 -0600 Subject: [PATCH] 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. --- components/layout/app-top-bar.tsx | 9 +- components/layout/habit-brand.tsx | 20 +++-- components/shop/shop-content.tsx | 63 +++++++++++--- components/shop/shop-item-preview-modal.tsx | 66 ++++++++++++++- lib/avatar-customization-storage.ts | 2 + lib/avatar-progression-storage.ts | 5 +- lib/character/presets.ts | 38 ++++++++- lib/coins.ts | 2 + .../20260525100000_shop_catalog_upsert.sql | 18 ++-- ...20260525130000_shop_item_display_names.sql | 10 +++ .../20260525140000_default_starting_coins.sql | 84 +++++++++++++++++++ supabase/seed.sql | 18 ++-- 12 files changed, 286 insertions(+), 49 deletions(-) create mode 100644 supabase/migrations/20260525130000_shop_item_display_names.sql create mode 100644 supabase/migrations/20260525140000_default_starting_coins.sql diff --git a/components/layout/app-top-bar.tsx b/components/layout/app-top-bar.tsx index e134cb0..46dac9e 100644 --- a/components/layout/app-top-bar.tsx +++ b/components/layout/app-top-bar.tsx @@ -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)", }} > -
- - +
+
); diff --git a/components/layout/habit-brand.tsx b/components/layout/habit-brand.tsx index 4ff6d16..2e16300 100644 --- a/components/layout/habit-brand.tsx +++ b/components/layout/habit-brand.tsx @@ -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 ( -
{!useText ? ( // eslint-disable-next-line @next/next/no-img-element @@ -37,6 +47,6 @@ export function HabitBrand({ className }: HabitBrandProps) { HaBit )} -
+ ); } diff --git a/components/shop/shop-content.tsx b/components/shop/shop-content.tsx index b910322..1c50a73 100644 --- a/components/shop/shop-content.tsx +++ b/components/shop/shop-content.tsx @@ -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(null); const [pendingItemId, setPendingItemId] = useState(null); const [baseCustomization, setBaseCustomization] = - useState(null); + useState(defaultAvatarCustomization); const [previewItem, setPreviewItem] = useState(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 ( <> setPreviewItem(null)} + onPurchase={() => { + if (previewItem) { + void handlePurchase(previewItem.id); + } + }} + onEquipToggle={() => { + if (previewItem) { + void handleEquipToggle(previewItem); + } + }} />
diff --git a/components/shop/shop-item-preview-modal.tsx b/components/shop/shop-item-preview-modal.tsx index 8fb704b..a1969db 100644 --- a/components/shop/shop-item-preview-modal.tsx +++ b/components/shop/shop-item-preview-modal.tsx @@ -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({

- This preview does not equip or purchase the item. + Preview only — nothing is purchased or equipped until you confirm.

+ +
+
+ Price + {item.price} points +
+ + {owned ? ( + + ) : ( + <> + {!canAfford && coins !== null ? ( +

+ You need {item.price - coins} more points to buy this item. + Keep completing habits to earn more. +

+ ) : null} + + + )} +
diff --git a/lib/avatar-customization-storage.ts b/lib/avatar-customization-storage.ts index d7b4320..450a90a 100644 --- a/lib/avatar-customization-storage.ts +++ b/lib/avatar-customization-storage.ts @@ -1,3 +1,4 @@ +import { DEFAULT_STARTING_COINS } from "@/lib/coins"; import { notifyHabitPetDataUpdated } from "@/lib/app-events"; import { hexToHsl, @@ -204,6 +205,7 @@ export async function getAvatarCustomization(): Promise { if (!data) { const { error: insertError } = await supabase.from("avatar_state").insert({ user_id: userId, + coins: DEFAULT_STARTING_COINS, avatar_name: DEFAULT_AVATAR_NAME, skin_color: DEFAULT_HEX, pants_style: NONE_VARIANT_ID, diff --git a/lib/avatar-progression-storage.ts b/lib/avatar-progression-storage.ts index faaf813..74bf1dc 100644 --- a/lib/avatar-progression-storage.ts +++ b/lib/avatar-progression-storage.ts @@ -1,3 +1,4 @@ +import { DEFAULT_STARTING_COINS } from "@/lib/coins"; import { createClient } from "@/lib/supabase/client"; async function getAuthenticatedUserId() { @@ -35,13 +36,13 @@ export async function getCoins(): Promise { if (!data) { const { error: insertError } = await supabase .from("avatar_state") - .insert({ user_id: userId, coins: 0 }); + .insert({ user_id: userId, coins: DEFAULT_STARTING_COINS }); if (insertError) { throw new Error(insertError.message); } - return 0; + return DEFAULT_STARTING_COINS; } return data.coins ?? 0; diff --git a/lib/character/presets.ts b/lib/character/presets.ts index bae21c3..d9831a7 100644 --- a/lib/character/presets.ts +++ b/lib/character/presets.ts @@ -59,6 +59,33 @@ function presetFromHex(id: string, name: string, hex: string): ColorPreset { return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b)); } +export const VARIANT_DISPLAY_NAMES: Record = { + "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( layerId: Exclude, label: string, @@ -66,9 +93,10 @@ function layerVariants( ): LayerVariant[] { return Array.from({ length: count }, (_, index) => { const number = index + 1; + const id = `${layerId}-${number}`; return { - id: `${layerId}-${number}`, - label: `${label} ${number}`, + id, + label: getVariantDisplayName(id, `${label} ${number}`), src: `/character/${layerId}/${layerId}-${number}.png`, }; }); @@ -117,7 +145,11 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [ label: "Skin", allowVariants: false, 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", + }, ], }, { diff --git a/lib/coins.ts b/lib/coins.ts index 8781247..30d4840 100644 --- a/lib/coins.ts +++ b/lib/coins.ts @@ -1,3 +1,5 @@ +export const DEFAULT_STARTING_COINS = 100; + export const COINS_PER_TASK_COMPLETE = 50; export const COINS_PER_TASK_UNCHECK = -50; diff --git a/supabase/migrations/20260525100000_shop_catalog_upsert.sql b/supabase/migrations/20260525100000_shop_catalog_upsert.sql index 95ae6e6..fd8a811 100644 --- a/supabase/migrations/20260525100000_shop_catalog_upsert.sql +++ b/supabase/migrations/20260525100000_shop_catalog_upsert.sql @@ -24,15 +24,15 @@ where id in ( insert into public.shop_items (id, name, type, price, image_path) values - ('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), - ('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), - ('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), - ('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'), - ('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'), - ('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'), - ('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'), - ('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'), - ('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'), + ('pants-3', 'Utility Cargo Pants', 'pants', 30, '/character/pants/pants-3.png'), + ('shoes-3', 'Neon Glow Runners', 'shoes', 30, '/character/shoes/shoes-3.png'), + ('torso-3', 'Cropped Tank Top', 'torso', 35, '/character/torso/torso-3.png'), + ('torso-4', 'Crimson Plate Armor', 'torso', 40, '/character/torso/torso-4.png'), + ('head-3', 'Retro Snapback Cap', 'head', 40, '/character/head/head-3.png'), + ('head-4', 'Fiery Side Mohawk', 'head', 45, '/character/head/head-4.png'), + ('head-5', 'Crested Warrior Helm', 'head', 50, '/character/head/head-5.png'), + ('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'), + ('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'), ('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'), ('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg') on conflict (id) do update diff --git a/supabase/migrations/20260525130000_shop_item_display_names.sql b/supabase/migrations/20260525130000_shop_item_display_names.sql new file mode 100644 index 0000000..1dd0213 --- /dev/null +++ b/supabase/migrations/20260525130000_shop_item_display_names.sql @@ -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'; diff --git a/supabase/migrations/20260525140000_default_starting_coins.sql b/supabase/migrations/20260525140000_default_starting_coins.sql new file mode 100644 index 0000000..0987156 --- /dev/null +++ b/supabase/migrations/20260525140000_default_starting_coins.sql @@ -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; +$$; diff --git a/supabase/seed.sql b/supabase/seed.sql index ceb6074..a3196b2 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -24,15 +24,15 @@ where id in ( insert into public.shop_items (id, name, type, price, image_path) values - ('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), - ('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), - ('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), - ('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'), - ('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'), - ('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'), - ('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'), - ('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'), - ('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'), + ('pants-3', 'Utility Cargo Pants', 'pants', 30, '/character/pants/pants-3.png'), + ('shoes-3', 'Neon Glow Runners', 'shoes', 30, '/character/shoes/shoes-3.png'), + ('torso-3', 'Cropped Tank Top', 'torso', 35, '/character/torso/torso-3.png'), + ('torso-4', 'Crimson Plate Armor', 'torso', 40, '/character/torso/torso-4.png'), + ('head-3', 'Retro Snapback Cap', 'head', 40, '/character/head/head-3.png'), + ('head-4', 'Fiery Side Mohawk', 'head', 45, '/character/head/head-4.png'), + ('head-5', 'Crested Warrior Helm', 'head', 50, '/character/head/head-5.png'), + ('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'), + ('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'), ('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'), ('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg') on conflict (id) do update