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