diff --git a/.vscode/settings.json b/.vscode/settings.json index 777ff13..b25e6c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "chat.tools.terminal.autoApprove": { - "codeql": true + "codeql": true, + "Get-CimInstance": true } } \ No newline at end of file diff --git a/src/components/Header.css b/src/components/Header.css index cefb6d1..5432441 100644 --- a/src/components/Header.css +++ b/src/components/Header.css @@ -1423,6 +1423,10 @@ body.theme-dark .profile-section { z-index: 2300; } + body.shop-preview-open .mobile-nav-dock { + display: none !important; + } + .mobile-nav-button { position: relative; display: flex; diff --git a/src/components/ProfilePage.tsx b/src/components/ProfilePage.tsx index 94e120c..76a94da 100644 --- a/src/components/ProfilePage.tsx +++ b/src/components/ProfilePage.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react'; import './ProfilePage.css'; import { getCurrentUser, fetchUserById } from '../services/authService'; import { ensureUserId } from '../utils/userHelpers'; +import { SHOP_AVATARS, SHOP_AVATAR_URLS } from '../data/avatarCatalog'; // Supabase auth metadata updates are handled server-side; no client import needed here import { useNavigate } from 'react-router-dom'; import SignOutButton from './SignOutButton'; @@ -80,11 +81,12 @@ const ProfilePage: React.FC = ({ userData, onProfileUpdate, pr } // Shop avatars (must match ShopPage) - const shopAvatars = [ - 'https://api.dicebear.com/7.x/bottts/svg?seed=FitBuddyAI1', - 'https://api.dicebear.com/7.x/bottts/svg?seed=DragonHead', - 'https://api.dicebear.com/7.x/bottts/svg?seed=Duolingo', - ]; + const shopAvatars = SHOP_AVATAR_URLS; + const ownedAvatarIds = new Set( + Array.isArray(user.inventory) + ? user.inventory.map((item: any) => String(item?.id || '')).filter(Boolean) + : [] + ); const premadeAvatars = [ '/images/fitbuddy_head.png', ...shopAvatars, @@ -261,8 +263,8 @@ const ProfilePage: React.FC = ({ userData, onProfileUpdate, pr <>
{premadeAvatars.map((url) => { - const isShopAvatar = shopAvatars.includes(url); - const ownsAvatar = !isShopAvatar || (Array.isArray(user.inventory) && user.inventory.some((item: any) => item.image === url)); + const shopAvatar = SHOP_AVATARS.find((avatar) => avatar.image === url); + const ownsAvatar = !shopAvatar || ownedAvatarIds.has(shopAvatar.id); return (
)} - {selectedTab === 'avatars' && filteredItems(AVATARS).map(item => { + {selectedTab === 'avatars' && filteredItems(SHOP_AVATARS).map(item => { const alreadyOwned = Array.isArray(user.inventory) && user.inventory.some((inv: any) => inv.id === item.id); return (
setPreview(item)}> @@ -320,53 +312,66 @@ const ShopPage: React.FC = ({ user, onPurchase, onRedeemStreakSav
{/* Preview modal */} - {preview && ( -
setPreview(null)}> -
e.stopPropagation()}> - -
-
- {/* Avatar preview if present */} - {preview.type === 'avatar' && preview.image && ( - {preview.name} - )} + {preview && (() => { + const previewOwned = Array.isArray(user?.inventory) && user.inventory.some((inv: any) => inv.id === preview.id); + return ( +
setPreview(null)}> +
e.stopPropagation()}> + +
+
+ {/* Avatar preview if present */} + {preview.type === 'avatar' && preview.image && ( + {preview.name} + )} - {/* Powerup demos mapped by id */} - {preview.type === 'powerup' && ( -
- {preview.id === 'spinpfp' &&
} - {preview.id === 'sparkle' &&
} - {preview.id === 'confetti' &&
🎉
} - {preview.id === 'glow' &&
} - {preview.id === 'trail' &&
} - {preview.id === 'animated-frames' &&
} - {preview.id === 'voice-chime' &&
🔔
} - {preview.id === 'vfx' &&
} - {preview.id === 'status-glow' &&
} - {/* default fallback */} - {!['spinpfp','sparkle','confetti','glow','trail','animated-frames','voice-chime','vfx','status-glow'].includes(preview.id) && ( -
Preview
- )} -
- )} -
+ {/* Powerup demos mapped by id */} + {preview.type === 'powerup' && ( +
+ {preview.id === 'spinpfp' &&
} + {preview.id === 'sparkle' &&
} + {preview.id === 'confetti' &&
🎉
} + {preview.id === 'glow' &&
} + {preview.id === 'trail' &&
} + {preview.id === 'animated-frames' &&
} + {preview.id === 'voice-chime' &&
🔔
} + {preview.id === 'vfx' &&
} + {preview.id === 'status-glow' &&
} + {/* default fallback */} + {!['spinpfp','sparkle','confetti','glow','trail','animated-frames','voice-chime','vfx','status-glow'].includes(preview.id) && ( +
Preview
+ )} +
+ )} +
-

{preview.name}

-

{preview.description}

-
- ⚡ {preview.price} -
- - +

{preview.name}

+

{preview.description}

+
+ ⚡ {preview.price} +
+ + +
-
- )} + ); + })()}
); }; diff --git a/src/data/avatarCatalog.ts b/src/data/avatarCatalog.ts new file mode 100644 index 0000000..4cac3c8 --- /dev/null +++ b/src/data/avatarCatalog.ts @@ -0,0 +1,26 @@ +export type AvatarCatalogItem = { + id: string; + name: string; + image: string; + price: number; + type: 'avatar'; + description: string; +}; + +export const SHOP_AVATARS: AvatarCatalogItem[] = [ + { id: 'avatar1', name: 'Bot Buddy', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=FitBuddyAI1', price: 100, type: 'avatar', description: 'A friendly robot avatar.' }, + { id: 'avatar2', name: 'Dragon Head', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=DragonHead', price: 200, type: 'avatar', description: 'Unleash your inner dragon!' }, + { id: 'avatar3', name: 'Duolingo Owl', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=Duolingo', price: 250, type: 'avatar', description: 'Inspired by the language learning legend.' }, + { id: 'avatar4', name: 'Neo Cat', image: 'https://api.dicebear.com/7.x/croodles/svg?seed=NeoCat', price: 120, type: 'avatar', description: 'A sleek cyber cat.' }, + { id: 'avatar5', name: 'Mountain Goat', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=Goat', price: 140, type: 'avatar', description: 'Sturdy and sure-footed.' }, + { id: 'avatar6', name: 'Galaxy Fox', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=GalaxyFox', price: 220, type: 'avatar', description: 'Out-of-this-world style.' }, + { id: 'avatar7', name: 'Pixel Pup', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=PixelPup', price: 80, type: 'avatar', description: 'Cute pixel-styled puppy.' }, + { id: 'avatar8', name: 'Samurai', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=Samurai', price: 300, type: 'avatar', description: 'Honor and style.' }, + { id: 'avatar9', name: 'Astronaut', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=Astronaut', price: 260, type: 'avatar', description: 'Reach for the stars.' }, + { id: 'avatar10', name: 'Vintage Robot', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=VintageBot', price: 110, type: 'avatar', description: 'Retro charm.' }, + { id: 'avatar11', name: 'Neon Ninja', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=NeonNinja', price: 210, type: 'avatar', description: 'Stealthy and bright.' }, + { id: 'avatar12', name: 'Forest Sprite', image: 'https://api.dicebear.com/7.x/bottts/svg?seed=ForestSprite', price: 130, type: 'avatar', description: 'Whimsical woodland friend.' }, + { id: 'avatar13', name: 'Forest Sprite', image: '/images/ChatGPT_Image_Clan_Of_27_Fire.png', price: 130, type: 'avatar', description: 'Whimsical woodland friend.' } +]; + +export const SHOP_AVATAR_URLS = SHOP_AVATARS.map((avatar) => avatar.image); \ No newline at end of file diff --git a/src/server/authServer.js b/src/server/authServer.js index 7c1d534..9548682 100644 --- a/src/server/authServer.js +++ b/src/server/authServer.js @@ -563,16 +563,76 @@ app.post('/api/user/buy', userBuyLimiter, async (req, res) => { return res.status(403).json({ message: 'Token does not match user.' }); } - const { data: updatedUser, error: purchaseError } = await supabase.rpc('buy_shop_item_atomic', { - p_user_id: id, - p_item: item - }); + const price = Number(item.price || 0); + if (!Number.isFinite(price) || price < 0) { + return res.status(400).json({ message: 'Invalid item price.' }); + } + + const { data: userData, error: fetchErr } = await supabase + .from('fitbuddyai_userdata') + .select('*') + .eq('user_id', id) + .limit(1) + .maybeSingle(); + if (fetchErr) { + console.error('[authServer] /api/user/buy fetch error', fetchErr); + return res.status(500).json({ message: 'Failed to load user.' }); + } + if (!userData) { + return res.status(404).json({ message: 'User not found.' }); + } + + const currentEnergy = Number(userData.energy || 0); + if (currentEnergy < price) { + return res.status(400).json({ message: 'Not enough energy.' }); + } + + const existingInventory = Array.isArray(userData.inventory) ? userData.inventory : []; + const itemId = String(item.id || ''); + const itemQuantity = Number.isFinite(Number(item.quantity ?? item.count)) ? Math.max(Number(item.quantity ?? item.count), 1) : 1; + const purchasedItem = { + ...item, + price, + quantity: itemQuantity, + purchased_at: new Date().toISOString() + }; - if (purchaseError) { - console.error('[authServer] /api/user/buy rpc error', purchaseError); - if (String(purchaseError.message || '').toLowerCase().includes('insufficient energy')) { - return res.status(400).json({ message: 'Not enough energy.' }); + let nextInventory = [...existingInventory, purchasedItem]; + if (itemId.startsWith('streak-saver')) { + const merged = []; + let mergedExisting = false; + for (const existingItem of existingInventory) { + if (!mergedExisting && String(existingItem?.id || '').startsWith('streak-saver')) { + const existingQuantity = Number(existingItem?.quantity ?? existingItem?.count ?? 1) || 1; + merged.push({ + ...existingItem, + price, + quantity: existingQuantity + itemQuantity, + purchased_at: new Date().toISOString() + }); + mergedExisting = true; + } else { + merged.push(existingItem); + } + } + if (!mergedExisting) { + merged.push(purchasedItem); } + nextInventory = merged; + } + + const newEnergy = currentEnergy - price; + const { data: updatedUser, error: updateErr } = await supabase + .from('fitbuddyai_userdata') + .update({ energy: newEnergy, inventory: nextInventory }) + .eq('user_id', id) + .select('*') + .maybeSingle(); + if (updateErr) { + console.error('[authServer] /api/user/buy update error', updateErr); + return res.status(500).json({ message: 'Failed to save purchase.' }); + } + if (!updatedUser) { return res.status(500).json({ message: 'Failed to save purchase.' }); } diff --git a/src/services/authService.ts b/src/services/authService.ts index 24aa29b..c8b7874 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -35,22 +35,34 @@ export async function buyShopItem(id: string, item: any): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, item: safeItem }) }); - const res = await fetch(`/api/user/${encodeURIComponent(id)}?action=buy`, reqInit); - if (!res.ok) { + const purchaseUrlCandidates = ['/api/user/buy', `/api/user/${encodeURIComponent(id)}?action=buy`]; + let lastError: any = null; + + for (const purchaseUrl of purchaseUrlCandidates) { + const res = await fetch(purchaseUrl, reqInit); + if (res.ok) { + const data = await res.json(); + if (data.user) { + try { saveUserData({ data: data.user }); } catch {} + return data.user; + } + return null; + } + // Attempt to read structured error code from server try { const err = await res.json(); - console.warn('[buyShopItem] server error', err.code || err.message || res.status); + lastError = err; + console.warn('[buyShopItem] server error', err.code || err.message || res.status, 'via', purchaseUrl); + if (res.status !== 404) break; } catch { - console.warn('[buyShopItem] server error status', res.status); + lastError = { status: res.status }; + console.warn('[buyShopItem] server error status', res.status, 'via', purchaseUrl); + if (res.status !== 404) break; } - return null; - } - const data = await res.json(); - if (data.user) { - try { saveUserData({ data: data.user }); } catch {} - return data.user; } + + if (lastError) return null; return null; } catch { return null; diff --git a/src/services/localStorage.ts b/src/services/localStorage.ts index 585bd8d..b30e0ff 100644 --- a/src/services/localStorage.ts +++ b/src/services/localStorage.ts @@ -29,6 +29,9 @@ const getStoredUserDataSnapshot = (): { data: any; timestamp: number } | null => try { if (userDataCache) return userDataCache; if (typeof window === 'undefined') return null; + const stored = sessionStorage.getItem(STORAGE_KEYS.USER_DATA); + const parsed = safeParseStored<{ data: any; timestamp: number }>(stored); + if (parsed && parsed.data) return parsed; const fallback = (window as any).fitbuddyai_user_data; if (!fallback || typeof fallback !== 'object') return null; const data = (fallback as any).data ?? fallback; @@ -204,6 +207,7 @@ export const saveUserData = (userData: any, opts?: { skipBackup?: boolean }): vo const payload = { ...toStore, timestamp: Date.now() }; userDataCache = payload; purgeLegacyUserStorage(); + try { sessionStorage.setItem(STORAGE_KEYS.USER_DATA, JSON.stringify(payload)); } catch {} // Broadcast to other tabs so they can sync without exposing the full profile on window try { const bc = new BroadcastChannel('fitbuddyai');