Skip to content

Commit 5f0bbaf

Browse files
committed
added shop functionality. Shop now pulls from db
1 parent 598d7f7 commit 5f0bbaf

3 files changed

Lines changed: 278 additions & 54 deletions

File tree

components/shop/shop-content.tsx

Lines changed: 105 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -12,55 +12,78 @@ import {
1212
CardTitle,
1313
} from "@/components/ui/card";
1414
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
15-
import { getCoins } from "@/lib/avatar-progression-storage";
15+
import {
16+
getShopState,
17+
purchaseShopItem,
18+
type ShopItem,
19+
} from "@/lib/shop-storage";
1620

17-
const shopItems = [
18-
{
19-
id: "hat",
20-
name: "Pixel cap",
21-
price: 50,
22-
description: "A cozy cap for your habit pet.",
23-
},
24-
{
25-
id: "bed",
26-
name: "Cloud bed",
27-
price: 120,
28-
description: "Boost recovery after tough days.",
29-
},
30-
{
31-
id: "snack",
32-
name: "Berry snack",
33-
price: 25,
34-
description: "A small treat for good streaks.",
35-
},
36-
];
21+
function formatItemType(type: string) {
22+
return type.charAt(0).toUpperCase() + type.slice(1);
23+
}
3724

3825
export function ShopContent() {
3926
const [coins, setCoins] = useState<number | null>(null);
27+
const [items, setItems] = useState<ShopItem[]>([]);
28+
const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]);
4029
const [error, setError] = useState<string | null>(null);
30+
const [purchaseError, setPurchaseError] = useState<string | null>(null);
31+
const [isLoading, setIsLoading] = useState(true);
32+
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
4133

42-
const refreshCoins = useCallback(async () => {
34+
const refreshShop = useCallback(async () => {
4335
try {
4436
setError(null);
45-
setCoins(await getCoins());
37+
const state = await getShopState();
38+
setCoins(state.coins);
39+
setItems(state.items);
40+
setOwnedItemIds(state.ownedItemIds);
4641
} catch (refreshError) {
4742
setError(
4843
refreshError instanceof Error
4944
? refreshError.message
50-
: "Could not load your balance.",
45+
: "Could not load the shop.",
5146
);
5247
setCoins(0);
48+
setItems([]);
49+
setOwnedItemIds([]);
50+
} finally {
51+
setIsLoading(false);
5352
}
5453
}, []);
5554

5655
useEffect(() => {
57-
void refreshCoins();
56+
void refreshShop();
5857

59-
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshCoins);
58+
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshShop);
6059
return () => {
61-
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshCoins);
60+
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refreshShop);
6261
};
63-
}, [refreshCoins]);
62+
}, [refreshShop]);
63+
64+
const handlePurchase = async (item: ShopItem) => {
65+
if (pendingItemId) {
66+
return;
67+
}
68+
69+
try {
70+
setPurchaseError(null);
71+
setPendingItemId(item.id);
72+
const remainingCoins = await purchaseShopItem(item.id);
73+
setCoins(remainingCoins);
74+
setOwnedItemIds((current) =>
75+
current.includes(item.id) ? current : [...current, item.id],
76+
);
77+
} catch (purchaseFailure) {
78+
setPurchaseError(
79+
purchaseFailure instanceof Error
80+
? purchaseFailure.message
81+
: "Could not complete purchase.",
82+
);
83+
} finally {
84+
setPendingItemId(null);
85+
}
86+
};
6487

6588
return (
6689
<>
@@ -72,35 +95,63 @@ export function ShopContent() {
7295
</div>
7396

7497
{error ? <p className="mb-4 text-sm text-red-500">{error}</p> : null}
98+
{purchaseError ? (
99+
<p className="mb-4 text-sm text-red-500">{purchaseError}</p>
100+
) : null}
75101

76-
<div className="grid gap-4">
77-
{shopItems.map((item) => {
78-
const canAfford = coins !== null && coins >= item.price;
102+
{isLoading ? (
103+
<p className="text-sm text-muted-foreground">Loading shop items...</p>
104+
) : items.length === 0 ? (
105+
<p className="text-sm text-muted-foreground">
106+
No items in the shop yet. Add rows to the shop_items table in
107+
Supabase.
108+
</p>
109+
) : (
110+
<div className="grid gap-4">
111+
{items.map((item) => {
112+
const isOwned = ownedItemIds.includes(item.id);
113+
const canAfford = coins !== null && coins >= item.price;
114+
const isPending = pendingItemId === item.id;
79115

80-
return (
81-
<Card key={item.id}>
82-
<CardHeader>
83-
<div className="flex items-start justify-between gap-3">
84-
<div>
85-
<CardTitle className="text-base">{item.name}</CardTitle>
86-
<CardDescription>{item.description}</CardDescription>
116+
return (
117+
<Card key={item.id}>
118+
<CardHeader>
119+
<div className="flex items-start justify-between gap-3">
120+
<div>
121+
<CardTitle className="text-base">{item.name}</CardTitle>
122+
<CardDescription>
123+
{formatItemType(item.type)}
124+
</CardDescription>
125+
</div>
126+
<Badge>{item.price} pts</Badge>
87127
</div>
88-
<Badge>{item.price} pts</Badge>
89-
</div>
90-
</CardHeader>
91-
<CardContent>
92-
<Button
93-
className="w-full"
94-
variant="outline"
95-
disabled={coins === null || !canAfford}
96-
>
97-
{canAfford ? "Buy" : "Not enough points"}
98-
</Button>
99-
</CardContent>
100-
</Card>
101-
);
102-
})}
103-
</div>
128+
</CardHeader>
129+
<CardContent>
130+
<Button
131+
className="w-full"
132+
variant={isOwned ? "secondary" : "outline"}
133+
disabled={
134+
isOwned ||
135+
coins === null ||
136+
isPending ||
137+
(!isOwned && !canAfford)
138+
}
139+
onClick={() => void handlePurchase(item)}
140+
>
141+
{isOwned
142+
? "Owned"
143+
: isPending
144+
? "Purchasing..."
145+
: canAfford
146+
? "Buy"
147+
: "Not enough points"}
148+
</Button>
149+
</CardContent>
150+
</Card>
151+
);
152+
})}
153+
</div>
154+
)}
104155
</>
105156
);
106157
}

lib/avatar-progression-storage.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,17 @@ export async function adjustCoins(delta: number): Promise<number> {
7272

7373
return nextCoins;
7474
}
75+
76+
export async function spendCoins(amount: number): Promise<number> {
77+
if (amount <= 0) {
78+
throw new Error("Purchase amount must be greater than zero.");
79+
}
80+
81+
const currentCoins = await getCoins();
82+
83+
if (currentCoins < amount) {
84+
throw new Error("Not enough points.");
85+
}
86+
87+
return adjustCoins(-amount);
88+
}

lib/shop-storage.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
2+
import { getCoins, spendCoins, adjustCoins } from "@/lib/avatar-progression-storage";
3+
import { createClient } from "@/lib/supabase/client";
4+
5+
export type ShopItem = {
6+
id: string;
7+
name: string;
8+
type: string;
9+
price: number;
10+
};
11+
12+
type ShopItemRow = {
13+
id: string;
14+
name: string;
15+
type: string;
16+
};
17+
18+
function getPriceForType(type: string) {
19+
switch (type.toLowerCase()) {
20+
case "accessory":
21+
return 30;
22+
case "background":
23+
return 40;
24+
case "outfit":
25+
return 50;
26+
case "consumable":
27+
case "snack":
28+
return 25;
29+
default:
30+
return 50;
31+
}
32+
}
33+
34+
function mapShopItemRow(row: ShopItemRow): ShopItem {
35+
return {
36+
id: row.id,
37+
name: row.name,
38+
type: row.type,
39+
price: getPriceForType(row.type),
40+
};
41+
}
42+
43+
async function getAuthenticatedUserId() {
44+
const supabase = createClient();
45+
const {
46+
data: { user },
47+
error,
48+
} = await supabase.auth.getUser();
49+
50+
if (error || !user) {
51+
return null;
52+
}
53+
54+
return user.id;
55+
}
56+
57+
export async function getShopItems(): Promise<ShopItem[]> {
58+
const supabase = createClient();
59+
const { data, error } = await supabase
60+
.from("shop_items")
61+
.select("id, name, type")
62+
.order("type", { ascending: true })
63+
.order("name", { ascending: true });
64+
65+
if (error) {
66+
throw new Error(error.message);
67+
}
68+
69+
return ((data ?? []) as ShopItemRow[]).map(mapShopItemRow);
70+
}
71+
72+
export async function getOwnedItemIds(): Promise<string[]> {
73+
const userId = await getAuthenticatedUserId();
74+
75+
if (!userId) {
76+
return [];
77+
}
78+
79+
const supabase = createClient();
80+
const { data, error } = await supabase
81+
.from("user_items")
82+
.select("item_id")
83+
.eq("user_id", userId);
84+
85+
if (error) {
86+
throw new Error(error.message);
87+
}
88+
89+
return (data ?? []).map((row) => row.item_id as string);
90+
}
91+
92+
export async function purchaseShopItem(itemId: string): Promise<number> {
93+
const userId = await getAuthenticatedUserId();
94+
95+
if (!userId) {
96+
throw new Error("You must be signed in to purchase items.");
97+
}
98+
99+
const supabase = createClient();
100+
const { data: itemRow, error: itemError } = await supabase
101+
.from("shop_items")
102+
.select("id, name, type")
103+
.eq("id", itemId)
104+
.maybeSingle();
105+
106+
if (itemError) {
107+
throw new Error(itemError.message);
108+
}
109+
110+
if (!itemRow) {
111+
throw new Error("Item not found.");
112+
}
113+
114+
const item = mapShopItemRow(itemRow as ShopItemRow);
115+
116+
const { data: existingPurchase, error: existingError } = await supabase
117+
.from("user_items")
118+
.select("item_id")
119+
.eq("user_id", userId)
120+
.eq("item_id", itemId)
121+
.maybeSingle();
122+
123+
if (existingError) {
124+
throw new Error(existingError.message);
125+
}
126+
127+
if (existingPurchase) {
128+
throw new Error("You already own this item.");
129+
}
130+
131+
const remainingCoins = await spendCoins(item.price);
132+
133+
const { error: purchaseError } = await supabase.from("user_items").insert({
134+
user_id: userId,
135+
item_id: itemId,
136+
});
137+
138+
if (purchaseError) {
139+
await adjustCoins(item.price);
140+
throw new Error(purchaseError.message);
141+
}
142+
143+
notifyHabitPetDataUpdated();
144+
return remainingCoins;
145+
}
146+
147+
export async function getShopState(): Promise<{
148+
coins: number;
149+
items: ShopItem[];
150+
ownedItemIds: string[];
151+
}> {
152+
const [coins, items, ownedItemIds] = await Promise.all([
153+
getCoins(),
154+
getShopItems(),
155+
getOwnedItemIds(),
156+
]);
157+
158+
return { coins, items, ownedItemIds };
159+
}

0 commit comments

Comments
 (0)