From 5fefc654126c31efe27b3c3891db3d212fe4626b Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 24 May 2026 04:02:56 -0600 Subject: [PATCH] Wire shop UI to RPCs and auto-equip after purchase. Refactor client storage to call Supabase RPCs and equip items immediately after a successful buy. --- components/shop/shop-content.tsx | 3 +- .../plans/2026-05-24-shop-implementation.md | 512 ++++++++++++++++++ lib/shop-storage.ts | 136 +---- 3 files changed, 533 insertions(+), 118 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-24-shop-implementation.md diff --git a/components/shop/shop-content.tsx b/components/shop/shop-content.tsx index bb18413..fbd114b 100644 --- a/components/shop/shop-content.tsx +++ b/components/shop/shop-content.tsx @@ -147,7 +147,8 @@ export function ShopContent() { try { const item = await purchaseShopItem(itemId); - toast(`Purchased ${item.name}.`, "success"); + await equipShopItem(itemId); + toast(`Purchased and equipped ${item.name}.`, "success"); await refreshShop(); } catch (purchaseError) { toast( diff --git a/docs/superpowers/plans/2026-05-24-shop-implementation.md b/docs/superpowers/plans/2026-05-24-shop-implementation.md new file mode 100644 index 0000000..bede5c5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-shop-implementation.md @@ -0,0 +1,512 @@ +# Shop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the existing shop end-to-end so `/shop` shows catalog items, users can buy with coins earned from habits, equip purchases, and see changes on the pet/room. + +**Architecture:** Primary fix is an idempotent migration that upserts `shop_items` (empty shop today). Harden buy/equip with Postgres RPCs that atomically deduct coins and update ownership/equip state. Client [`lib/shop-storage.ts`](../../lib/shop-storage.ts) calls RPCs; UI auto-equips after purchase. + +**Tech Stack:** Next.js App Router, Supabase Postgres + RLS, existing shop UI in [`components/shop/shop-content.tsx`](../../components/shop/shop-content.tsx) + +**Brainstorming decisions:** +- Scope: fix existing shop (not rebuild) +- Blocker: empty shop (`shop_items` not populated in prod) +- Catalog fix: migration upsert (not client fallback) + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| `supabase/migrations/20260525100000_shop_catalog_upsert.sql` | Idempotent catalog rows + legacy cleanup | +| `supabase/migrations/20260525110000_shop_purchase_and_equip.sql` | Atomic purchase/equip RPCs | +| `supabase/seed.sql` | Stay in sync with catalog migration | +| `lib/shop-storage.ts` | Call RPCs for buy/equip; keep reads client-side | +| `components/shop/shop-content.tsx` | Auto-equip after buy, surface RPC errors | + +--- + +### Task 1: Shop catalog migration (fixes empty shop) + +**Files:** +- Create: `supabase/migrations/20260525100000_shop_catalog_upsert.sql` +- Modify: `supabase/seed.sql` + +- [ ] **Step 1: Create catalog migration** + +```sql +-- Idempotent shop catalog. Fixes empty shop when seed did not run. + +delete from public.user_items +where item_id in ('hat_blue', 'glasses_round', 'room_sunset', 'room-space', 'room-neon'); + +delete from public.shop_items +where id in ( + 'hat_blue', + 'glasses_round', + 'room_sunset', + 'room-space', + 'room-neon', + 'pants-1', + 'pants-2', + 'shoes-1', + 'shoes-2', + 'torso-1', + 'torso-2', + 'eyes-1', + 'eyes-2', + 'head-1', + 'head-2' +); + +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'), + ('room-night', 'Starlit Night', 'room', 40, ''), + ('room-forest', 'Mystic Forest', 'room', 50, '') +on conflict (id) do update +set + name = excluded.name, + type = excluded.type, + price = excluded.price, + image_path = excluded.image_path; +``` + +- [ ] **Step 2: Align `supabase/seed.sql`** + +Replace the delete/insert block in `supabase/seed.sql` with the same catalog SQL as Step 1 (keep file identical to migration body so local `db reset` and prod deploy stay aligned). + +- [ ] **Step 3: Verify migration applies locally** + +Run: `npx supabase db reset` (if local Supabase running) OR push to linked project: `npx supabase db push --linked` + +Run: `npx supabase db execute --linked --sql "select count(*) from shop_items;"` + +Expected: `11` rows + +- [ ] **Step 4: Commit** + +```bash +git add supabase/migrations/20260525100000_shop_catalog_upsert.sql supabase/seed.sql +git commit -m "fix: upsert shop catalog via migration so shop is never empty" +``` + +--- + +### Task 2: Postgres RPCs for atomic purchase and equip + +**Files:** +- Create: `supabase/migrations/20260525110000_shop_purchase_and_equip.sql` + +- [ ] **Step 1: Create RPC migration** + +```sql +-- Atomic shop purchase and equip operations. + +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, 0); + v_coins := 0; + 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; +$$; + +create or replace function public.equip_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_equipped text[]; + v_layer text; +begin + if v_user_id is null then + raise exception 'You must be signed in to equip items.'; + end if; + + if not exists ( + select 1 from public.user_items + where user_id = v_user_id and item_id = p_item_id + ) then + raise exception 'Purchase this item before equipping it.'; + end if; + + select * into v_item from public.shop_items where id = p_item_id; + if not found then + raise exception 'Could not find that shop item.'; + end if; + + if v_item.type = 'room' then + update public.avatar_state + set room_background = p_item_id, updated_at = now() + where user_id = v_user_id; + + return jsonb_build_object('equipped_room', p_item_id); + end if; + + select equipped_items into v_equipped + from public.avatar_state + where user_id = v_user_id + for update; + + v_equipped := coalesce(v_equipped, '{}'); + + -- Remove any equipped item on the same layer + v_equipped := array( + select e from unnest(v_equipped) as e + where e = p_item_id + or not (e like v_item.type || '-%') + ); + + v_equipped := array_append(v_equipped, p_item_id); + + v_layer := v_item.type; + + update public.avatar_state + set + equipped_items = v_equipped, + equipped_item = p_item_id, + head_style = case when v_layer = 'head' then p_item_id else head_style end, + torso_style = case when v_layer = 'torso' then p_item_id else torso_style end, + pants_style = case when v_layer = 'pants' then p_item_id else pants_style end, + shoe_style = case when v_layer = 'shoes' then p_item_id else shoe_style end, + eye_type = case when v_layer = 'eyes' then p_item_id else eye_type end, + updated_at = now() + where user_id = v_user_id; + + return jsonb_build_object('equipped_items', to_jsonb(v_equipped)); +end; +$$; + +create or replace function public.unequip_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_equipped text[]; + v_layer text; +begin + if v_user_id is null then + raise exception 'You must be signed in to unequip items.'; + end if; + + select * into v_item from public.shop_items where id = p_item_id; + if not found then + raise exception 'Could not find that shop item.'; + end if; + + if v_item.type = 'room' then + raise exception 'Room backgrounds cannot be unequipped.'; + end if; + + select equipped_items into v_equipped + from public.avatar_state + where user_id = v_user_id + for update; + + v_equipped := coalesce(v_equipped, '{}'); + v_equipped := array(select e from unnest(v_equipped) as e where e <> p_item_id); + v_layer := v_item.type; + + update public.avatar_state + set + equipped_items = v_equipped, + equipped_item = coalesce(v_equipped[1], 'none'), + head_style = case when v_layer = 'head' then 'none' else head_style end, + torso_style = case when v_layer = 'torso' then 'none' else torso_style end, + pants_style = case when v_layer = 'pants' then 'none' else pants_style end, + shoe_style = case when v_layer = 'shoes' then 'none' else shoe_style end, + eye_type = case when v_layer = 'eyes' then 'none' else eye_type end, + updated_at = now() + where user_id = v_user_id; + + return jsonb_build_object('equipped_items', to_jsonb(v_equipped)); +end; +$$; + +revoke all on function public.purchase_shop_item(text) from public; +revoke all on function public.equip_shop_item(text) from public; +revoke all on function public.unequip_shop_item(text) from public; + +grant execute on function public.purchase_shop_item(text) to authenticated; +grant execute on function public.equip_shop_item(text) to authenticated; +grant execute on function public.unequip_shop_item(text) to authenticated; +``` + +- [ ] **Step 2: Apply migration** + +Run: `npx supabase db push --linked` (or `db reset` locally) + +Expected: functions created without error + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/20260525110000_shop_purchase_and_equip.sql +git commit -m "feat: add atomic shop purchase and equip RPCs" +``` + +--- + +### Task 3: Wire client storage to RPCs + +**Files:** +- Modify: `lib/shop-storage.ts` + +- [ ] **Step 1: Replace `purchaseShopItem`** + +Remove `adjustCoins` import usage from purchase path. Replace function body: + +```typescript +export async function purchaseShopItem(itemId: string) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to purchase items."); + } + + const supabase = createClient(); + const { data, error } = await supabase.rpc("purchase_shop_item", { + p_item_id: itemId, + }); + + if (error) { + throw new Error(error.message); + } + + notifyHabitPetDataUpdated(); + + return { + id: data.item_id as string, + name: data.name as string, + type: data.type as ShopItemRecord["type"], + price: data.price as number, + image_path: data.image_path as string, + }; +} +``` + +- [ ] **Step 2: Replace `equipShopItem`** + +```typescript +export async function equipShopItem(itemId: string) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to equip items."); + } + + const supabase = createClient(); + const { error } = await supabase.rpc("equip_shop_item", { + p_item_id: itemId, + }); + + if (error) { + throw new Error(error.message); + } + + notifyHabitPetDataUpdated(); + return getEquippedItems(); +} +``` + +- [ ] **Step 3: Replace `unequipShopItem`** + +```typescript +export async function unequipShopItem(itemId: string) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to unequip items."); + } + + const supabase = createClient(); + const { error } = await supabase.rpc("unequip_shop_item", { + p_item_id: itemId, + }); + + if (error) { + throw new Error(error.message); + } + + notifyHabitPetDataUpdated(); + return getEquippedItems(); +} +``` + +- [ ] **Step 4: Remove unused import** + +Remove `adjustCoins` from the import at top of `lib/shop-storage.ts` if no longer used. Keep `getCoins` for `getShopInventory`. + +- [ ] **Step 5: Run lint** + +Run: `npm run lint` + +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add lib/shop-storage.ts +git commit -m "refactor: use Supabase RPCs for shop purchase and equip" +``` + +--- + +### Task 4: Shop UX — auto-equip after purchase + +**Files:** +- Modify: `components/shop/shop-content.tsx` + +- [ ] **Step 1: Auto-equip in `handlePurchase`** + +After successful purchase, call equip: + +```typescript +const handlePurchase = async (itemId: string) => { + setPendingItemId(itemId); + + try { + const item = await purchaseShopItem(itemId); + await equipShopItem(itemId); + toast(`Purchased and equipped ${item.name}.`, "success"); + await refreshShop(); + } catch (purchaseError) { + toast( + purchaseError instanceof Error + ? purchaseError.message + : "Purchase failed.", + "error", + ); + } finally { + setPendingItemId(null); + } +}; +``` + +- [ ] **Step 2: Run lint and build** + +Run: `npm run lint && npm run build` + +Expected: pass + +- [ ] **Step 3: Commit** + +```bash +git add components/shop/shop-content.tsx +git commit -m "feat: auto-equip shop items after purchase" +``` + +--- + +### Task 5: End-to-end verification + +- [ ] **Step 1: Local smoke test** + +1. Start app: `npm run dev` +2. Sign in, complete onboarding if needed +3. Visit `/shop` — expect ~11 items in grouped sections (not empty message) +4. Complete one habit on `/avatar` or `/habits` — shop balance should show 50 points +5. Buy `shoes-3` (30 pts) — balance 20, item shows Equipped +6. Visit `/avatar` — pet shows shoes-3 sprite +7. Visit `/customize` — shoes-3 selectable (owned) +8. Try buying same item again — error toast "already own" +9. Buy with 20 pts an item costing 40 — error "Not enough points", balance unchanged + +- [ ] **Step 2: Push and verify deploy** + +```bash +git push origin main +``` + +Watch GitHub Actions Deploy workflow. Expected: success, `supabase db push --include-seed` applies new migrations. + +- [ ] **Step 3: Production smoke test** + +Visit https://cursor-hackathon-flame.vercel.app/shop — catalog visible, buy/equip flow works. + +--- + +## Success criteria + +| Check | Expected | +|-------|----------| +| `/shop` loads items | 11 catalog rows, grouped by layer + rooms | +| Earn coins | +50 per habit complete | +| Buy | Deducts coins, adds `user_items` row | +| Equip | Updates pet sprite / room on `/avatar` | +| Customize | Owned variants unlocked | +| Errors | Clear messages for insufficient points / already owned | + +## Out of scope + +- Coin rewards from daily quiz +- Next.js API route layer (RPCs sufficient) +- New shop item types beyond existing layer styles + rooms diff --git a/lib/shop-storage.ts b/lib/shop-storage.ts index de6f564..df105da 100644 --- a/lib/shop-storage.ts +++ b/lib/shop-storage.ts @@ -1,9 +1,6 @@ import { notifyHabitPetDataUpdated } from "@/lib/app-events"; -import { adjustCoins, getCoins } from "@/lib/avatar-progression-storage"; -import { - parseVariantId, - type ShopItemRecord, -} from "@/lib/shop-catalog"; +import { getCoins } from "@/lib/avatar-progression-storage"; +import { type ShopItemRecord } from "@/lib/shop-catalog"; import { createClient } from "@/lib/supabase/client"; async function getAuthenticatedUserId() { @@ -83,48 +80,22 @@ export async function purchaseShopItem(itemId: string) { } const supabase = createClient(); - const { data: item, error: itemError } = await supabase - .from("shop_items") - .select("id, name, type, price, image_path") - .eq("id", itemId) - .maybeSingle(); - - if (itemError) { - throw new Error(itemError.message); - } - - if (!item) { - throw new Error("That shop item is no longer available."); - } - - const owned = await getOwnedItemIds(); - - if (owned.includes(itemId)) { - throw new Error("You already own this item."); - } - - const coins = await getCoins(); - - if (coins < item.price) { - throw new Error("Not enough points for this purchase."); - } - - await adjustCoins(-item.price); - - const { error: purchaseError } = await supabase.from("user_items").insert({ - user_id: userId, - item_id: itemId, + const { data, error } = await supabase.rpc("purchase_shop_item", { + p_item_id: itemId, }); - if (purchaseError) { - await adjustCoins(item.price); - throw new Error(purchaseError.message); + if (error) { + throw new Error(error.message); } notifyHabitPetDataUpdated(); + return { - ...(item as ShopItemRecord), - type: item.type as ShopItemRecord["type"], + id: data.item_id as string, + name: data.name as string, + type: data.type as ShopItemRecord["type"], + price: data.price as number, + image_path: data.image_path as string, }; } @@ -160,78 +131,17 @@ export async function equipShopItem(itemId: string) { throw new Error("You must be signed in to equip items."); } - const owned = await getOwnedItemIds(); - - if (!owned.includes(itemId)) { - throw new Error("Purchase this item before equipping it."); - } - const supabase = createClient(); - const { data: item, error: itemError } = await supabase - .from("shop_items") - .select("id, type") - .eq("id", itemId) - .maybeSingle(); - - if (itemError || !item) { - throw new Error("Could not find that shop item."); - } - - if (item.type === "room") { - const { error } = await supabase - .from("avatar_state") - .update({ - room_background: itemId, - updated_at: new Date().toISOString(), - }) - .eq("user_id", userId); - - if (error) { - if (/room_background/i.test(error.message)) { - notifyHabitPetDataUpdated(); - return [itemId]; - } - - throw new Error(error.message); - } - - notifyHabitPetDataUpdated(); - return [itemId]; - } - - const current = await getEquippedItems(); - const nextStyle = parseVariantId(itemId); - - const withoutSameLayer = current.filter((equippedId) => { - if (equippedId === itemId) { - return false; - } - - const equippedStyle = parseVariantId(equippedId); - - if (!nextStyle || !equippedStyle) { - return true; - } - - return equippedStyle.layerId !== nextStyle.layerId; + const { error } = await supabase.rpc("equip_shop_item", { + p_item_id: itemId, }); - const next = [...withoutSameLayer, itemId]; - - const { error } = await supabase - .from("avatar_state") - .update({ - equipped_items: next, - equipped_item: itemId, - updated_at: new Date().toISOString(), - }) - .eq("user_id", userId); if (error) { throw new Error(error.message); } notifyHabitPetDataUpdated(); - return next; + return getEquippedItems(); } export async function unequipShopItem(itemId: string) { @@ -241,25 +151,17 @@ export async function unequipShopItem(itemId: string) { throw new Error("You must be signed in to unequip items."); } - const current = await getEquippedItems(); - const next = current.filter((id) => id !== itemId); - const supabase = createClient(); - const { error } = await supabase - .from("avatar_state") - .update({ - equipped_items: next, - equipped_item: next[0] ?? "none", - updated_at: new Date().toISOString(), - }) - .eq("user_id", userId); + const { error } = await supabase.rpc("unequip_shop_item", { + p_item_id: itemId, + }); if (error) { throw new Error(error.message); } notifyHabitPetDataUpdated(); - return next; + return getEquippedItems(); } export async function getShopInventory() {