import { useCallback, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; // Stash item stored in encrypted NIP-51 list export interface StashItem { id: string; title: string; category: 'deck' | 'trucks' | 'wheels' | 'bearings' | 'shoes' | 'clothing' | 'protective' | 'complete' | 'other'; condition: 'new' | 'like_new' | 'good' | 'well_used'; size?: string; brand?: string; description?: string; minPrice?: number; // Minimum sats willing to accept maxPrice?: number; // Maximum sats willing to pay (for WTB) images?: string[]; willingToTrade?: boolean; willingToSell?: boolean; addedAt: number; // Visibility settings visibility: 'private' | 'friends' | 'public'; // Optional geohash for local matching geohash?: string; // Item type: regular stash item or Want To Buy itemType?: 'stash' | 'wtb'; // Rarity override (for special items) rarity?: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; // Physical storage location storageLocation?: 'home' | 'car' | 'skatepark' | 'warehouse' | 'storage_unit' | 'van' | 'friends' | 'parents' | 'wardrobe' | 'backpack' | 'sponsor' | 'other'; } export interface Stash { items: StashItem[]; updatedAt: number; } // Kind 30001 is NIP-51 "Follow sets" - we use a private encrypted list const STASH_KIND = 30001; const STASH_D_TAG = 'skatespots-stash'; /** * Hook to manage user's private gear stash * Uses NIP-44 encryption to store items privately */ export function useStash() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const queryClient = useQueryClient(); const { mutateAsync: createEvent } = useNostrPublish(); // Determine if nip44 is safely available WITHOUT touching any throwing getter. // Extension signers throw "Browser extension not available" from their .nostr // getter when window.nostr isn't present yet (page load race, or no extension). // We check the login type instead: nsec and bunker have nip44 built-in always; // extension logins only have it when window.nostr is present; amber never has it. const hasNip44 = useMemo(() => { if (!user?.pubkey) return false; const login = logins.find((l) => l.pubkey === user.pubkey); if (!login) return false; // nsec and bunker: nip44 is always available (pure JS, no external dep) if (login.type === 'nsec' || login.type === 'bunker') return true; // extension: safe to check window.nostr directly (no getter, no throw) if (login.type === 'extension') return !!(globalThis as { nostr?: { nip44?: unknown } }).nostr?.nip44; // amber / unknown: no nip44 support return false; }, [user?.pubkey, logins]); // Fetch and decrypt stash const { data: stash, isLoading, error } = useQuery({ queryKey: ['stash', user?.pubkey], queryFn: async ({ signal }): Promise => { if (!user?.pubkey || !hasNip44) { return { items: [], updatedAt: 0 }; } const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]); // Fetch the encrypted stash event const events = await nostr.query([{ kinds: [STASH_KIND], authors: [user.pubkey], '#d': [STASH_D_TAG], limit: 1, }], { signal: combinedSignal }); if (events.length === 0) { return { items: [], updatedAt: 0 }; } const event = events[0]; try { // Decrypt the content (encrypted to self) — access nip44 inside try/catch // to guard against extension signers throwing when window.nostr is gone let nip44Dec: typeof user.signer.nip44; try { nip44Dec = user.signer?.nip44; } catch { return { items: [], updatedAt: 0 }; } if (!nip44Dec) return { items: [], updatedAt: 0 }; const decrypted = await nip44Dec.decrypt(user.pubkey, event.content); const stashData = JSON.parse(decrypted) as Stash; return stashData; } catch (err) { console.error('[Stash] Failed to decrypt stash:', err); return { items: [], updatedAt: 0 }; } }, enabled: !!user?.pubkey && hasNip44, staleTime: 60000, // 1 minute }); // Save stash (encrypt and publish) const saveStash = useCallback(async (newStash: Stash) => { if (!user?.pubkey || !hasNip44) { throw new Error('Encryption not available'); } // Access nip44 inside a try/catch at call time (signer is guaranteed by hasNip44 above) let nip44Enc: typeof user.signer.nip44; try { nip44Enc = user.signer?.nip44; } catch { throw new Error('Encryption not available'); } if (!nip44Enc) throw new Error('Encryption not available'); // Encrypt to self const encrypted = await nip44Enc.encrypt(user.pubkey, JSON.stringify(newStash)); // Publish as replaceable event await createEvent({ kind: STASH_KIND, content: encrypted, tags: [ ['d', STASH_D_TAG], ['alt', 'Private gear stash (encrypted)'], ], }); // Invalidate cache queryClient.invalidateQueries({ queryKey: ['stash', user.pubkey] }); }, [user, createEvent, queryClient]); // Add item mutation const addItem = useMutation({ mutationFn: async (item: Omit) => { const currentStash = stash || { items: [], updatedAt: 0 }; const newItem: StashItem = { ...item, id: crypto.randomUUID(), addedAt: Math.floor(Date.now() / 1000), }; const newStash: Stash = { items: [...currentStash.items, newItem], updatedAt: Math.floor(Date.now() / 1000), }; await saveStash(newStash); return newItem; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); // Update item mutation const updateItem = useMutation({ mutationFn: async (item: StashItem) => { const currentStash = stash || { items: [], updatedAt: 0 }; const newStash: Stash = { items: currentStash.items.map(i => i.id === item.id ? item : i), updatedAt: Math.floor(Date.now() / 1000), }; await saveStash(newStash); return item; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); // Remove item mutation const removeItem = useMutation({ mutationFn: async (itemId: string) => { const currentStash = stash || { items: [], updatedAt: 0 }; const newStash: Stash = { items: currentStash.items.filter(i => i.id !== itemId), updatedAt: Math.floor(Date.now() / 1000), }; await saveStash(newStash); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); // Stats for RPG display const stats = useMemo(() => { if (!stash) return { total: 0, byCategory: {}, totalValue: 0 }; const byCategory: Record = {}; let totalValue = 0; for (const item of stash.items) { byCategory[item.category] = (byCategory[item.category] || 0) + 1; if (item.minPrice) totalValue += item.minPrice; } return { total: stash.items.length, byCategory, totalValue, }; }, [stash]); return { stash, items: stash?.items || [], stats, isLoading, error, addItem, updateItem, removeItem, isEncryptionAvailable: !!user?.signer?.nip44, }; } // Category metadata for RPG display export const STASH_CATEGORIES = { deck: { emoji: '🛹', label: 'Decks', color: '#ff0040', rarity: 'common' }, trucks: { emoji: '🔧', label: 'Trucks', color: '#ff6600', rarity: 'common' }, wheels: { emoji: '⚙️', label: 'Wheels', color: '#ccff00', rarity: 'common' }, bearings: { emoji: '🔩', label: 'Bearings', color: '#00ff88', rarity: 'uncommon' }, shoes: { emoji: '👟', label: 'Shoes', color: '#00ccff', rarity: 'uncommon' }, clothing: { emoji: '👕', label: 'Clothing', color: '#9933ff', rarity: 'common' }, protective: { emoji: '🛡️', label: 'Protective', color: '#ff33cc', rarity: 'rare' }, complete: { emoji: '🎯', label: 'Completes', color: '#ffd700', rarity: 'epic' }, other: { emoji: '📦', label: 'Other', color: '#888888', rarity: 'common' }, } as const; export const CONDITION_LABELS = { new: { label: '🆕 New', color: '#00ff00', stars: 5 }, like_new: { label: '✨ Like New', color: '#ccff00', stars: 4 }, good: { label: '👍 Good', color: '#ffcc00', stars: 3 }, well_used: { label: '⚠️ Well Used', color: '#ff6600', stars: 2 }, } as const; // RPG-style rarity tiers with multipliers for XP export const RARITY_TIERS = { common: { label: 'Common', color: '#9ca3af', xpMultiplier: 1, bgGradient: 'from-gray-500/20 to-gray-600/20' }, uncommon: { label: 'Uncommon', color: '#22c55e', xpMultiplier: 1.5, bgGradient: 'from-green-500/20 to-emerald-600/20' }, rare: { label: 'Rare', color: '#3b82f6', xpMultiplier: 2, bgGradient: 'from-blue-500/20 to-indigo-600/20' }, epic: { label: 'Epic', color: '#a855f7', xpMultiplier: 3, bgGradient: 'from-purple-500/20 to-violet-600/20' }, legendary: { label: 'Legendary', color: '#fbbf24', xpMultiplier: 5, bgGradient: 'from-yellow-400/20 to-orange-500/20' }, } as const; // Calculate player level based on total items and value export function calculateSkaterLevel(stats: { total: number; totalValue: number; byCategory: Record }): { level: number; currentXP: number; xpToNextLevel: number; title: string; progress: number; } { // XP formula: 100 per item + value/100 const xp = stats.total * 100 + Math.floor(stats.totalValue / 100); // Level formula: sqrt(xp/100) const level = Math.max(1, Math.floor(Math.sqrt(xp / 100))); const xpForCurrentLevel = level * level * 100; const xpForNextLevel = (level + 1) * (level + 1) * 100; const currentXP = xp - xpForCurrentLevel; const xpToNextLevel = xpForNextLevel - xpForCurrentLevel; const progress = Math.min(100, (currentXP / xpToNextLevel) * 100); // Titles based on level const titles: Record = { 1: 'Grom', 2: 'Pusher', 3: 'Street Rat', 5: 'Park Local', 8: 'Session Regular', 12: 'Spot Hunter', 15: 'Gear Head', 20: 'Skate Collector', 25: 'Board Hoarder', 30: 'Stash Master', 50: 'Legendary Collector', }; let title = 'Grom'; for (const [lvl, t] of Object.entries(titles)) { if (level >= parseInt(lvl)) title = t; } return { level, currentXP, xpToNextLevel, title, progress }; }