From 5f0bbaf3cf2a67c65e2b5f1a5c950cd85b23ba9d Mon Sep 17 00:00:00 2001 From: masongga Date: Sat, 23 May 2026 22:46:58 -0600 Subject: [PATCH] added shop functionality. Shop now pulls from db --- components/shop/shop-content.tsx | 159 ++++++++++++++++++++---------- lib/avatar-progression-storage.ts | 14 +++ lib/shop-storage.ts | 159 ++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+), 54 deletions(-) create mode 100644 lib/shop-storage.ts diff --git a/components/shop/shop-content.tsx b/components/shop/shop-content.tsx index 66e1719..5a74fdf 100644 --- a/components/shop/shop-content.tsx +++ b/components/shop/shop-content.tsx @@ -12,55 +12,78 @@ import { CardTitle, } from "@/components/ui/card"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; -import { getCoins } from "@/lib/avatar-progression-storage"; +import { + getShopState, + purchaseShopItem, + type ShopItem, +} from "@/lib/shop-storage"; -const shopItems = [ - { - id: "hat", - name: "Pixel cap", - price: 50, - description: "A cozy cap for your habit pet.", - }, - { - id: "bed", - name: "Cloud bed", - price: 120, - description: "Boost recovery after tough days.", - }, - { - id: "snack", - name: "Berry snack", - price: 25, - description: "A small treat for good streaks.", - }, -]; +function formatItemType(type: string) { + return type.charAt(0).toUpperCase() + type.slice(1); +} export function ShopContent() { const [coins, setCoins] = useState(null); + const [items, setItems] = useState([]); + const [ownedItemIds, setOwnedItemIds] = useState([]); const [error, setError] = useState(null); + const [purchaseError, setPurchaseError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [pendingItemId, setPendingItemId] = useState(null); - const refreshCoins = useCallback(async () => { + const refreshShop = useCallback(async () => { try { setError(null); - setCoins(await getCoins()); + const state = await getShopState(); + setCoins(state.coins); + setItems(state.items); + setOwnedItemIds(state.ownedItemIds); } catch (refreshError) { setError( refreshError instanceof Error ? refreshError.message - : "Could not load your balance.", + : "Could not load the shop.", ); setCoins(0); + setItems([]); + setOwnedItemIds([]); + } finally { + setIsLoading(false); } }, []); useEffect(() => { - void refreshCoins(); + void refreshShop(); - window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshCoins); + window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshShop); return () => { - window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshCoins); + window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshShop); }; - }, [refreshCoins]); + }, [refreshShop]); + + const handlePurchase = async (item: ShopItem) => { + if (pendingItemId) { + return; + } + + try { + setPurchaseError(null); + setPendingItemId(item.id); + const remainingCoins = await purchaseShopItem(item.id); + setCoins(remainingCoins); + setOwnedItemIds((current) => + current.includes(item.id) ? current : [...current, item.id], + ); + } catch (purchaseFailure) { + setPurchaseError( + purchaseFailure instanceof Error + ? purchaseFailure.message + : "Could not complete purchase.", + ); + } finally { + setPendingItemId(null); + } + }; return ( <> @@ -72,35 +95,63 @@ export function ShopContent() { {error ?

{error}

: null} + {purchaseError ? ( +

{purchaseError}

+ ) : null} -
- {shopItems.map((item) => { - const canAfford = coins !== null && coins >= item.price; + {isLoading ? ( +

Loading shop items...

+ ) : items.length === 0 ? ( +

+ No items in the shop yet. Add rows to the shop_items table in + Supabase. +

+ ) : ( +
+ {items.map((item) => { + const isOwned = ownedItemIds.includes(item.id); + const canAfford = coins !== null && coins >= item.price; + const isPending = pendingItemId === item.id; - return ( - - -
-
- {item.name} - {item.description} + return ( + + +
+
+ {item.name} + + {formatItemType(item.type)} + +
+ {item.price} pts
- {item.price} pts -
- - - - - - ); - })} -
+
+ + + +
+ ); + })} +
+ )} ); } diff --git a/lib/avatar-progression-storage.ts b/lib/avatar-progression-storage.ts index d083bb4..faaf813 100644 --- a/lib/avatar-progression-storage.ts +++ b/lib/avatar-progression-storage.ts @@ -72,3 +72,17 @@ export async function adjustCoins(delta: number): Promise { return nextCoins; } + +export async function spendCoins(amount: number): Promise { + if (amount <= 0) { + throw new Error("Purchase amount must be greater than zero."); + } + + const currentCoins = await getCoins(); + + if (currentCoins < amount) { + throw new Error("Not enough points."); + } + + return adjustCoins(-amount); +} diff --git a/lib/shop-storage.ts b/lib/shop-storage.ts new file mode 100644 index 0000000..598a805 --- /dev/null +++ b/lib/shop-storage.ts @@ -0,0 +1,159 @@ +import { notifyHabitPetDataUpdated } from "@/lib/app-events"; +import { getCoins, spendCoins, adjustCoins } from "@/lib/avatar-progression-storage"; +import { createClient } from "@/lib/supabase/client"; + +export type ShopItem = { + id: string; + name: string; + type: string; + price: number; +}; + +type ShopItemRow = { + id: string; + name: string; + type: string; +}; + +function getPriceForType(type: string) { + switch (type.toLowerCase()) { + case "accessory": + return 30; + case "background": + return 40; + case "outfit": + return 50; + case "consumable": + case "snack": + return 25; + default: + return 50; + } +} + +function mapShopItemRow(row: ShopItemRow): ShopItem { + return { + id: row.id, + name: row.name, + type: row.type, + price: getPriceForType(row.type), + }; +} + +async function getAuthenticatedUserId() { + const supabase = createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + return null; + } + + return user.id; +} + +export async function getShopItems(): Promise { + const supabase = createClient(); + const { data, error } = await supabase + .from("shop_items") + .select("id, name, type") + .order("type", { ascending: true }) + .order("name", { ascending: true }); + + if (error) { + throw new Error(error.message); + } + + return ((data ?? []) as ShopItemRow[]).map(mapShopItemRow); +} + +export async function getOwnedItemIds(): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return []; + } + + const supabase = createClient(); + const { data, error } = await supabase + .from("user_items") + .select("item_id") + .eq("user_id", userId); + + if (error) { + throw new Error(error.message); + } + + return (data ?? []).map((row) => row.item_id as string); +} + +export async function purchaseShopItem(itemId: string): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to purchase items."); + } + + const supabase = createClient(); + const { data: itemRow, error: itemError } = await supabase + .from("shop_items") + .select("id, name, type") + .eq("id", itemId) + .maybeSingle(); + + if (itemError) { + throw new Error(itemError.message); + } + + if (!itemRow) { + throw new Error("Item not found."); + } + + const item = mapShopItemRow(itemRow as ShopItemRow); + + const { data: existingPurchase, error: existingError } = await supabase + .from("user_items") + .select("item_id") + .eq("user_id", userId) + .eq("item_id", itemId) + .maybeSingle(); + + if (existingError) { + throw new Error(existingError.message); + } + + if (existingPurchase) { + throw new Error("You already own this item."); + } + + const remainingCoins = await spendCoins(item.price); + + const { error: purchaseError } = await supabase.from("user_items").insert({ + user_id: userId, + item_id: itemId, + }); + + if (purchaseError) { + await adjustCoins(item.price); + throw new Error(purchaseError.message); + } + + notifyHabitPetDataUpdated(); + return remainingCoins; +} + +export async function getShopState(): Promise<{ + coins: number; + items: ShopItem[]; + ownedItemIds: string[]; +}> { + const [coins, items, ownedItemIds] = await Promise.all([ + getCoins(), + getShopItems(), + getOwnedItemIds(), + ]); + + return { coins, items, ownedItemIds }; +}