import type { NostrEvent } from '@nostrify/nostrify'; // Community relay for all skate-specific content (spots, events, coaches, shops, etc.) // This ensures all users see the same content regardless of their personal relay settings export const SKATE_RELAY = 'wss://goskatespots.nostr1.com'; export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey+kind+d-tag export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace) export const CALENDAR_EVENT_KIND = 31923; export const SKATE_COACH_KIND = 35001; export const SKATE_FILMER_KIND = 35002; // Skate filmer/photographer profiles export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots export interface SkateSpot { id: string; pubkey: string; identifier: string; title: string; description: string; geohash: string; location?: string; images: string[]; category?: string; // Primary category: street, park, diy, plaza tags: string[]; // Freeform tags (features, surfaces, vibes) types: string[]; // Legacy - combined category + tags for backwards compat accessType?: 'public' | 'private' | 'locals'; contactInfo?: string; createdAt: number; event: NostrEvent; } export interface SkateShop { id: string; pubkey: string; identifier: string; name: string; description: string; geohash: string; location?: string; images: string[]; phone?: string; email?: string; website?: string; instagram?: string; hours?: string; createdAt: number; event: NostrEvent; } export interface SkateEvent { id: string; pubkey: string; identifier: string; title: string; description: string; start: number; end?: number; geohash?: string; location?: string; image?: string; types: string[]; createdAt: number; event: NostrEvent; } export interface FoodSpot { id: string; name: string; lat: number; lng: number; type: string; cuisine?: string; address?: string; phone?: string; website?: string; openingHours?: string; distance?: number; } export function parseSkateSpot(event: NostrEvent): SkateSpot | null { if (event.kind !== SKATE_SPOT_KIND) return null; // Must have 'skatespot' tag to be identified as a spot event const hasSkateSpotTag = event.tags.some(([name, value]) => name === 't' && value === 'skatespot'); if (!hasSkateSpotTag) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; if (!d || !title || !geohash) return null; const location = event.tags.find(([name]) => name === 'location')?.[1]; // Support both 'image' (old) and 'imeta' (new, NIP-92) tags const imageTags = event.tags.filter(([name]) => name === 'image').map(([, url]) => url); const imetaTags = event.tags .filter(([name]) => name === 'imeta') .map((tag) => { // imeta format: ['imeta', 'url https://...', 'm image/jpeg', ...] const urlPart = tag.find((part) => part.startsWith('url ')); return urlPart ? urlPart.replace('url ', '') : null; }) .filter(Boolean) as string[]; const images = [...imageTags, ...imetaTags]; // Get category from dedicated tag (new format) const category = event.tags.find(([name]) => name === 'category')?.[1]; // Get all t tags except 'skatespot' const allTags = event.tags .filter(([name, value]) => name === 't' && value !== 'skatespot') .map(([, value]) => value) .filter(Boolean) as string[]; // Separate category-level tags (street, park, diy, plaza) from feature tags const categoryValues = ['street', 'park', 'diy', 'plaza', 'transition']; const legacyCategory = allTags.find(t => categoryValues.includes(t)); const tags = allTags.filter(t => !categoryValues.includes(t)); // types is legacy - combine category + tags for backwards compat const types = allTags; const accessType = event.tags.find(([name]) => name === 'access')?.[1] as 'public' | 'private' | 'locals' | undefined; const contactInfo = event.tags.find(([name]) => name === 'contact')?.[1]; return { id: event.id, pubkey: event.pubkey, identifier: d, title, description: event.content, geohash, location, images, category: category || legacyCategory, // Prefer new format, fallback to legacy tags, types, accessType, contactInfo, createdAt: event.created_at, event, }; } export function parseSkateShop(event: NostrEvent): SkateShop | null { if (event.kind !== SKATE_SHOP_KIND) return null; // Must have 'skateshop' tag to be identified as a shop event const hasShopTag = event.tags.some(([name, value]) => name === 't' && value === 'skateshop'); if (!hasShopTag) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const name = event.tags.find(([name]) => name === 'name')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; if (!d || !name || !geohash) return null; const location = event.tags.find(([name]) => name === 'location')?.[1]; // Support both 'image' and 'imeta' tags const imageTags = event.tags.filter(([name]) => name === 'image').map(([, url]) => url); const imetaTags = event.tags .filter(([name]) => name === 'imeta') .map((tag) => { const urlPart = tag.find((part) => part.startsWith('url ')); return urlPart ? urlPart.replace('url ', '') : null; }) .filter(Boolean) as string[]; const images = [...imageTags, ...imetaTags]; const phone = event.tags.find(([name]) => name === 'phone')?.[1]; const email = event.tags.find(([name]) => name === 'email')?.[1]; const website = event.tags.find(([name]) => name === 'website')?.[1]; const instagram = event.tags.find(([name]) => name === 'instagram')?.[1]; const hours = event.tags.find(([name]) => name === 'hours')?.[1]; return { id: event.id, pubkey: event.pubkey, identifier: d, name, description: event.content, geohash, location, images, phone, email, website, instagram, hours, createdAt: event.created_at, event, }; } export function parseSkateEvent(event: NostrEvent): SkateEvent | null { if (event.kind !== CALENDAR_EVENT_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; const start = event.tags.find(([name]) => name === 'start')?.[1]; // Must have skateboarding tag const hasSkateboardingTag = event.tags.some( ([name, value]) => name === 't' && value === 'skateboarding' ); if (!d || !title || !start || !hasSkateboardingTag) return null; const end = event.tags.find(([name]) => name === 'end')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const location = event.tags.find(([name]) => name === 'location')?.[1]; const image = event.tags.find(([name]) => name === 'image')?.[1]; const types = event.tags .filter(([name, value]) => name === 't' && value !== 'skateboarding') .map(([, value]) => value) .filter(Boolean) as string[]; return { id: event.id, pubkey: event.pubkey, identifier: d, title, description: event.content, start: parseInt(start), end: end ? parseInt(end) : undefined, geohash, location, image, types, createdAt: event.created_at, event, }; } // Main spot categories (mutually exclusive - user picks ONE) export const SPOT_CATEGORIES = { street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' }, transition: { label: 'Transition', emoji: '🌊', description: 'Curved/ramped surfaces' }, park: { label: 'Park', emoji: '🏟️', description: 'Built skateparks' }, plaza: { label: 'Plaza', emoji: '📍', description: 'Urban plazas (like MACBA, Love Park)' }, } as const; // Spot features organized by category export const SPOT_FEATURES = { // Street features wallrides: { label: 'Wallrides', emoji: '🧱', category: 'street' }, stairs: { label: 'Stairs & Handrails', emoji: '🪜', category: 'street' }, rails: { label: 'Rails', emoji: '🚧', category: 'street' }, ledges: { label: 'Ledges & Curbs', emoji: '⬜', category: 'street' }, gaps: { label: 'Gaps', emoji: '🕳️', category: 'street' }, flatground: { label: 'Flatground', emoji: '⬛', category: 'street' }, 'manual-pads': { label: 'Manual Pads', emoji: '⚖️', category: 'street' }, // Transition features 'half-pipes': { label: 'Half Pipes', emoji: '🌊', category: 'transition' }, 'quarter-pipes': { label: 'Quarter Pipes', emoji: '🌀', category: 'transition' }, 'mini-ramps': { label: 'Mini Ramps', emoji: '🎢', category: 'transition' }, bowls: { label: 'Bowls', emoji: '🥣', category: 'transition' }, vert: { label: 'Vert Ramps', emoji: '🔝', category: 'transition' }, 'mega-ramps': { label: 'Mega Ramps', emoji: '🚀', category: 'transition' }, // Park-specific features (only in Park/Plaza) hubbas: { label: 'Hubbas', emoji: '📐', category: 'park' }, 'euro-gaps': { label: 'Euro Gaps', emoji: '🔀', category: 'park' }, banks: { label: 'Banks', emoji: '📈', category: 'park' }, 'spine-ramps': { label: 'Spine Ramps', emoji: '🔃', category: 'park' }, 'roll-ins': { label: 'Roll-ins', emoji: '⬇️', category: 'park' }, hips: { label: 'Hips', emoji: '↗️', category: 'park' }, volcano: { label: 'Volcano', emoji: '🌋', category: 'park' }, pyramids: { label: 'Pyramids / Funboxes', emoji: '🔺', category: 'park' }, 'snake-runs': { label: 'Snake Runs', emoji: '🐍', category: 'park' }, 'pump-track': { label: 'Pump Track Rollers', emoji: '🔄', category: 'park' }, 'flat-bars': { label: 'Flat Bars', emoji: '➖', category: 'park' }, } as const; // Get features available for a category export function getFeaturesForCategory(category: string): Array<{ key: string; label: string; emoji: string }> { const allStreetFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'street') .map(([key, v]) => ({ key, label: v.label, emoji: v.emoji })); const allTransitionFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'transition') .map(([key, v]) => ({ key, label: v.label, emoji: v.emoji })); const allParkFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'park') .map(([key, v]) => ({ key, label: v.label, emoji: v.emoji })); switch (category) { case 'street': return allStreetFeatures; case 'transition': return allTransitionFeatures; case 'park': case 'plaza': // Parks and Plazas get ALL features return [...allStreetFeatures, ...allTransitionFeatures, ...allParkFeatures]; default: return []; } } // Get all features as flat list (for filters) export function getAllFeatures(): Array<{ key: string; label: string; emoji: string; category: string }> { return Object.entries(SPOT_FEATURES).map(([key, v]) => ({ key, label: v.label, emoji: v.emoji, category: v.category, })); } // Legacy SPOT_TYPE_LABELS for backwards compatibility (combines categories + features) export const SPOT_TYPE_LABELS: Record = { // Categories street: 'Street', transition: 'Transition', park: 'Park', plaza: 'Plaza', // Features (for display/filtering) wallrides: 'Wallrides', stairs: 'Stairs & Handrails', rails: 'Rails', ledges: 'Ledges & Curbs', gaps: 'Gaps', flatground: 'Flatground', 'manual-pads': 'Manual Pads', 'half-pipes': 'Half Pipes', 'quarter-pipes': 'Quarter Pipes', 'mini-ramps': 'Mini Ramps', bowls: 'Bowls', vert: 'Vert Ramps', 'mega-ramps': 'Mega Ramps', hubbas: 'Hubbas', 'euro-gaps': 'Euro Gaps', banks: 'Banks', 'spine-ramps': 'Spine Ramps', 'roll-ins': 'Roll-ins', hips: 'Hips', volcano: 'Volcano', pyramids: 'Pyramids / Funboxes', 'snake-runs': 'Snake Runs', 'pump-track': 'Pump Track Rollers', 'flat-bars': 'Flat Bars', // Legacy (deprecated but kept for backwards compat) diy: 'DIY', rail: 'Rail', ledge: 'Ledge', gap: 'Gap', bowl: 'Bowl', stall: 'Stall', slappy: 'Slappy', }; export const ACCESS_TYPE_INFO = { public: { emoji: '🌍', label: 'Public - Open to Everyone', color: '#ff0040' }, private: { emoji: '🔒', label: 'Private Property - Permission Needed', color: '#ff6600' }, locals: { emoji: '👥', label: 'Ask Locals First', color: '#ccff00' }, }; export const EVENT_TYPE_LABELS: Record = { contest: 'Contest', demo: 'Demo', session: 'Session', workshop: 'Workshop', premiere: 'Premiere', tradeshow: 'Trade Show', gameskate: 'Game of Skate', // Music & Culture concert: '🎸 Concert', jam: '🎵 Jam', festival: '🎪 Festival', popup: '🎯 Pop-Up', }; export const COACH_SKILL_LABELS: Record = { basics: 'Basics', ollies: 'Ollies', kickflips: 'Kickflips', grinds: 'Grinds & Slides', manuals: 'Manuals', inverts: 'Inverts', vert: 'Vert/Ramp', bowl: 'Bowl', street: 'Street', freestyle: 'Freestyle', safety: 'Safety', diy: 'DIY Skate Modules', }; export const COACH_SKILL_DESCRIPTIONS: Record = { basics: 'Pushing, stance, balance, turning', ollies: 'Ollie and variations (nollie, fakie)', kickflips: 'Kickflip, heelflip, and variations', grinds: '50-50, 5-0, boardslide, and more', manuals: 'Manual, nose manual, combos', inverts: 'Invert, handplant, eggplant, andrecht', vert: 'Half-pipe, quarter-pipe, drop-ins', bowl: 'Bowl carving, pumping, lines', street: 'Street obstacles and creativity', freestyle: 'Flatground tricks and flow', safety: 'How to fall, protective gear, injury prevention', diy: 'Building ramps, rails, ledges, and other skate obstacles', }; // Filming service types export const FILMING_SERVICE_LABELS: Record = { miniclips: 'Mini Clips', longparts: 'Long Video Parts', koth: 'King of the Road Style', documentaries: 'Documentaries', contests: 'Filming for Contests', raw: 'Raw Footage', photos: 'Action Photography', }; export const FILMING_SERVICE_DESCRIPTIONS: Record = { miniclips: '15-60 sec clips for social media', longparts: '3-10 min full video parts with music', koth: 'Adventure/challenge style skate videos', documentaries: 'Skate culture and story-driven content', contests: 'Professional contest and competition coverage', raw: 'Unedited multi-angle footage', photos: 'Sequence shots and peak moments', }; // Filming skills/equipment export const FILMING_SKILL_LABELS: Record = { vx1000: 'VX1000', dslr: 'DSLR/Mirrorless', phone: 'Phone Filming', drone: 'Drone/Aerial', fisheye: 'Fisheye Lens', longlens: 'Long Lens', editing: 'Video Editing', color: 'Color Grading', }; export const FILMING_SKILL_DESCRIPTIONS: Record = { vx1000: 'Classic VX1000 skateboarding aesthetic', dslr: 'High quality DSLR or mirrorless camera', phone: 'Mobile filming with modern smartphones', drone: 'Aerial and drone footage', fisheye: 'Wide-angle fisheye lens shots', longlens: 'Long lens for detailed shots', editing: 'Professional video editing and post-production', color: 'Color correction and grading', }; // Video editor service types export const EDITOR_SERVICE_LABELS: Record = { parts: 'Video Parts', montages: 'Montages', reels: 'Reels / Short Clips', documentaries: 'Documentaries', contest: 'Contest Edits', brand: 'Brand / Promo Videos', motion: 'Motion Graphics', colorgrade: 'Color Grading Only', }; export const EDITOR_SERVICE_DESCRIPTIONS: Record = { parts: 'Full skate video parts with soundtrack', montages: 'Compilation edits from raw footage', reels: '15–60 sec social media clips & reels', documentaries: 'Story-driven skate culture films', contest: 'Recap edits for contests and events', brand: 'Promotional content for brands and shops', motion: 'Titles, transitions, effects, and graphics', colorgrade: 'Colour correction and LUT-based grading', }; // Hardware / software skills for editors export const EDITOR_SKILL_LABELS: Record = { premiere: 'Adobe Premiere', resolve: 'DaVinci Resolve', finalcut: 'Final Cut Pro', aftereffects: 'After Effects', luts: 'Custom LUTs', vhs: 'VHS / Retro Aesthetic', '4k': '4K & RAW Workflows', sound: 'Sound Design / Mix', }; export const EDITOR_SKILL_DESCRIPTIONS: Record = { premiere: 'Adobe Premiere Pro editing', resolve: 'DaVinci Resolve editing & grading', finalcut: 'Final Cut Pro editing', aftereffects: 'Motion graphics and compositing', luts: 'Custom colour look development', vhs: 'VHS glitch, super-8, retro film looks', '4k': 'High-res RAW/LOG camera workflows', sound: 'Music sync, SFX, audio mastering', }; export interface SkateCoach { id: string; pubkey: string; identifier: string; title: string; bio: string; geohash?: string; location?: string; image?: string; skills: string[]; experience?: string; rate?: string; phone?: string; email?: string; website?: string; instagram?: string; spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag" createdAt: number; event: NostrEvent; } export interface SkateFilmer { id: string; pubkey: string; identifier: string; title: string; bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // Combined services and filming skills experience?: string; // Years of experience rate?: string; phone?: string; email?: string; website?: string; social?: string; // Social media handle or link spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag" createdAt: number; event: NostrEvent; } export interface SkateEditor { id: string; pubkey: string; identifier: string; title: string; bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // Combined editor services + software/hardware skills experience?: string; // Years of experience rate?: string; phone?: string; email?: string; website?: string; portfolio?: string; // Portfolio link (Vimeo, YouTube, etc.) social?: string; hardware?: string; // e.g. "MacBook Pro M3, Sony FX3" software?: string; // e.g. "DaVinci Resolve, Premiere" createdAt: number; event: NostrEvent; } export interface SkateTour { id: string; pubkey: string; identifier: string; title: string; description: string; category: TourCategory; // local, national, international, destruction geohash?: string; // City-level location (low precision for privacy) location?: string; // City/region name meetingPoint?: string; // Public meeting spot name meetingPointCoords?: { lat: number; lon: number }; image?: string; duration?: number; // Hours for local, days for national/international durationUnit?: 'hours' | 'days' | 'weeks'; skillLevel?: 'beginner' | 'intermediate' | 'advanced' | 'all'; spotTypes: string[]; // street, park, diy, transition maxGroupSize?: number; price?: number; // Sats (host decides) availability?: string; // e.g., "weekends", "by appointment" (for local/national) languages?: string[]; shopRef?: string; // Reference to partner shop (naddr) shopName?: string; // Partner shop name shopDiscount?: number; // Percentage discount at partner shop // National/International/Destruction specific cities?: string[]; // List of cities for multi-city tours countries?: string[]; // List of countries for international/destruction tours transportInfo?: string; // How participants will travel accommodationInfo?: string; // Accommodation recommendations // Organizer contact (for destruction tours) organizerContact?: string; // Contact details for the organizer // Destruction Tour specific startDate?: number; // Unix timestamp - when competition starts (challenges revealed) endDate?: number; // Unix timestamp - when competition ends entryFee?: number; // Sats per team prizePool?: number; // Total prize in sats prizeDistribution?: { place: number; percentage: number }[]; // e.g., [{place: 1, percentage: 50}] maxTeams?: number; minTeamSize?: number; maxTeamSize?: number; challenges?: DestructionChallenge[]; acceptedClipSources?: ('blossom' | 'youtube' | 'instagram' | 'any')[]; // What clip sources organizer accepts // Film release options (for destruction tours) filmRelease?: { atShops: boolean; // Teams' films can premiere at local skate shops online: boolean; // Films also released online releaseDate?: number; // Unix timestamp for film release (after competition ends) }; // Sponsors (for future use) sponsors?: TourSponsor[]; createdAt: number; event: NostrEvent; } // Tour sponsor interface export interface TourSponsor { name: string; logo?: string; url?: string; tier?: 'title' | 'gold' | 'silver' | 'challenge' | 'partner'; challengeId?: string; // If sponsoring a specific challenge } export function parseSkateCoach(event: NostrEvent): SkateCoach | null { if (event.kind !== SKATE_COACH_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; if (!d || !title) return null; const location = event.tags.find(([name]) => name === 'location')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const image = event.tags.find(([name]) => name === 'image')?.[1]; const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skatecoach').map(([, value]) => value); const experience = event.tags.find(([name]) => name === 'experience')?.[1]; const rate = event.tags.find(([name]) => name === 'rate')?.[1]; const phone = event.tags.find(([name]) => name === 'phone')?.[1]; const email = event.tags.find(([name]) => name === 'email')?.[1]; const website = event.tags.find(([name]) => name === 'website')?.[1]; const instagram = event.tags.find(([name]) => name === 'instagram')?.[1]; // Extract spot references (a tags pointing to kind 30818 spots) const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) .map(([, value]) => value); return { id: event.id, pubkey: event.pubkey, identifier: d, title, bio: event.content, location, geohash, image, skills, experience, rate, phone, email, website, instagram, spotRefs: spotRefs.length > 0 ? spotRefs : undefined, createdAt: event.created_at, event, }; } export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null { if (event.kind !== SKATE_FILMER_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; if (!d || !title) return null; const location = event.tags.find(([name]) => name === 'location')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const image = event.tags.find(([name]) => name === 'image')?.[1]; const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skatefilmer').map(([, value]) => value); const experience = event.tags.find(([name]) => name === 'experience')?.[1]; const rate = event.tags.find(([name]) => name === 'rate')?.[1]; const phone = event.tags.find(([name]) => name === 'phone')?.[1]; const email = event.tags.find(([name]) => name === 'email')?.[1]; const website = event.tags.find(([name]) => name === 'website')?.[1]; const social = event.tags.find(([name]) => name === 'social')?.[1]; // Extract spot references (a tags pointing to kind 30818 spots) const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) .map(([, value]) => value); return { id: event.id, pubkey: event.pubkey, identifier: d, title, bio: event.content, location, geohash, image, skills, experience, rate, phone, email, website, social, spotRefs: spotRefs.length > 0 ? spotRefs : undefined, createdAt: event.created_at, event, }; } export function parseSkateEditor(event: NostrEvent): SkateEditor | null { if (event.kind !== SKATE_EDITOR_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; if (!d || !title) return null; const location = event.tags.find(([name]) => name === 'location')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const image = event.tags.find(([name]) => name === 'image')?.[1]; const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skateeditor').map(([, value]) => value); const experience = event.tags.find(([name]) => name === 'experience')?.[1]; const rate = event.tags.find(([name]) => name === 'rate')?.[1]; const phone = event.tags.find(([name]) => name === 'phone')?.[1]; const email = event.tags.find(([name]) => name === 'email')?.[1]; const website = event.tags.find(([name]) => name === 'website')?.[1]; const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1]; const social = event.tags.find(([name]) => name === 'social')?.[1]; const hardware = event.tags.find(([name]) => name === 'hardware')?.[1]; const software = event.tags.find(([name]) => name === 'software')?.[1]; return { id: event.id, pubkey: event.pubkey, identifier: d, title, bio: event.content, location, geohash, image, skills, experience, rate, phone, email, website, portfolio, social, hardware, software, createdAt: event.created_at, event, }; } export function parseSkateTour(event: NostrEvent): SkateTour | null { if (event.kind !== SKATE_TOUR_KIND) return null; // Must have 'skate-tour' tag const hasTourTag = event.tags.some(([name, value]) => name === 't' && value === 'skate-tour'); if (!hasTourTag) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; if (!d || !title) return null; // Tour category - default to 'local' for backwards compatibility const categoryTag = event.tags.find(([name]) => name === 'tour-category')?.[1]; const category = (categoryTag as TourCategory) || 'local'; const location = event.tags.find(([name]) => name === 'location')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const meetingPoint = event.tags.find(([name]) => name === 'meeting-point')?.[1]; const meetingCoordsTag = event.tags.find(([name]) => name === 'meeting-point-coords'); const meetingPointCoords = meetingCoordsTag ? { lat: parseFloat(meetingCoordsTag[1]), lon: parseFloat(meetingCoordsTag[2]) } : undefined; const image = event.tags.find(([name]) => name === 'image')?.[1]; const durationTag = event.tags.find(([name]) => name === 'duration'); const duration = durationTag ? parseFloat(durationTag[1]) : undefined; const durationUnit = durationTag?.[2] as SkateTour['durationUnit'] || 'hours'; const skillLevel = event.tags.find(([name]) => name === 'skill-level')?.[1] as SkateTour['skillLevel']; // Filter out category tags when getting spot types const categoryTags = ['skate-tour', 'local-tour', 'national-tour', 'international-tour', 'destruction-tour']; const spotTypes = event.tags .filter(([name, value]) => name === 't' && !categoryTags.includes(value)) .map(([, value]) => value); const maxGroupTag = event.tags.find(([name]) => name === 'max-group')?.[1]; const maxGroupSize = maxGroupTag ? parseInt(maxGroupTag) : undefined; const priceTag = event.tags.find(([name]) => name === 'price')?.[1]; const price = priceTag ? parseInt(priceTag) : undefined; const availability = event.tags.find(([name]) => name === 'available')?.[1]; const languagesTag = event.tags.find(([name]) => name === 'languages'); const languages = languagesTag ? languagesTag.slice(1) : undefined; const shopRef = event.tags.find(([name]) => name === 'shop')?.[1]; const shopName = event.tags.find(([name]) => name === 'shop-name')?.[1]; const shopDiscountTag = event.tags.find(([name]) => name === 'shop-discount')?.[1]; const shopDiscount = shopDiscountTag ? parseInt(shopDiscountTag) : undefined; // National/International fields const citiesTag = event.tags.find(([name]) => name === 'cities'); const cities = citiesTag ? citiesTag.slice(1) : undefined; const countriesTag = event.tags.find(([name]) => name === 'countries'); const countries = countriesTag ? countriesTag.slice(1) : undefined; const transportInfo = event.tags.find(([name]) => name === 'transport')?.[1]; const accommodationInfo = event.tags.find(([name]) => name === 'accommodation')?.[1]; const organizerContact = event.tags.find(([name]) => name === 'organizer-contact')?.[1]; // Destruction Tour fields const startDateTag = event.tags.find(([name]) => name === 'start-date')?.[1]; const startDate = startDateTag ? parseInt(startDateTag) : undefined; const endDateTag = event.tags.find(([name]) => name === 'end-date')?.[1]; const endDate = endDateTag ? parseInt(endDateTag) : undefined; const entryFeeTag = event.tags.find(([name]) => name === 'entry-fee')?.[1]; const entryFee = entryFeeTag ? parseInt(entryFeeTag) : undefined; const prizePoolTag = event.tags.find(([name]) => name === 'prize-pool')?.[1]; const prizePool = prizePoolTag ? parseInt(prizePoolTag) : undefined; // Parse prize distribution: ["prize-dist", "1", "50", "2", "30", "3", "20"] const prizeDistTag = event.tags.find(([name]) => name === 'prize-dist'); let prizeDistribution: { place: number; percentage: number }[] | undefined; if (prizeDistTag && prizeDistTag.length > 1) { prizeDistribution = []; for (let i = 1; i < prizeDistTag.length; i += 2) { if (prizeDistTag[i] && prizeDistTag[i + 1]) { prizeDistribution.push({ place: parseInt(prizeDistTag[i]), percentage: parseInt(prizeDistTag[i + 1]), }); } } } const maxTeamsTag = event.tags.find(([name]) => name === 'max-teams')?.[1]; const maxTeams = maxTeamsTag ? parseInt(maxTeamsTag) : undefined; const minTeamSizeTag = event.tags.find(([name]) => name === 'min-team-size')?.[1]; const minTeamSize = minTeamSizeTag ? parseInt(minTeamSizeTag) : undefined; const maxTeamSizeTag = event.tags.find(([name]) => name === 'max-team-size')?.[1]; const maxTeamSize = maxTeamSizeTag ? parseInt(maxTeamSizeTag) : undefined; // Parse challenges from tags - format: ["challenge", "id", "title", "description", "points", "revealDate"] // All challenges are secret until the tour start date by default let challenges: DestructionChallenge[] | undefined; const challengesTags = event.tags.filter(([name]) => name === 'challenge'); if (challengesTags.length > 0) { challenges = challengesTags.map(tag => ({ id: tag[1] || '', title: tag[2] || '', description: tag[3] || undefined, points: parseInt(tag[4] || '0'), isSecret: true, // All challenges are secret until start date revealDate: tag[5] ? parseInt(tag[5]) : startDate, // Defaults to tour start date })); } // Parse accepted clip sources const clipSourcesTag = event.tags.find(([name]) => name === 'clip-sources'); const acceptedClipSources = clipSourcesTag ? clipSourcesTag.slice(1) as ('blossom' | 'youtube' | 'instagram' | 'any')[] : undefined; // Parse film release options - format: ["film-release", "shops", "online"] const filmReleaseTag = event.tags.find(([name]) => name === 'film-release'); const filmReleaseDateTag = event.tags.find(([name]) => name === 'film-release-date')?.[1]; let filmRelease: SkateTour['filmRelease']; if (filmReleaseTag) { const options = filmReleaseTag.slice(1); filmRelease = { atShops: options.includes('shops'), online: options.includes('online'), releaseDate: filmReleaseDateTag ? parseInt(filmReleaseDateTag) : undefined, }; } // Parse sponsors - format: ["sponsor", "tier", "name", "logo-url", "website-url", "challengeId?"] const sponsorTags = event.tags.filter(([name]) => name === 'sponsor'); let sponsors: TourSponsor[] | undefined; if (sponsorTags.length > 0) { sponsors = sponsorTags.map(tag => ({ tier: tag[1] as TourSponsor['tier'], name: tag[2] || '', logo: tag[3] || undefined, url: tag[4] || undefined, challengeId: tag[5] || undefined, })); } return { id: event.id, pubkey: event.pubkey, identifier: d, title, description: event.content, category, location, geohash, meetingPoint, meetingPointCoords, image, duration, durationUnit, skillLevel, spotTypes, maxGroupSize, price, availability, languages, shopRef, shopName, shopDiscount, cities, countries, transportInfo, accommodationInfo, organizerContact, startDate, endDate, entryFee, prizePool, prizeDistribution, maxTeams, minTeamSize, maxTeamSize, challenges, acceptedClipSources, filmRelease, sponsors, createdAt: event.created_at, event, }; } // Tour skill levels export const TOUR_SKILL_LEVELS = { beginner: { label: 'Beginner', emoji: '🌱', description: 'New to skating or basic tricks' }, intermediate: { label: 'Intermediate', emoji: '🔥', description: 'Comfortable with most terrain' }, advanced: { label: 'Advanced', emoji: '💀', description: 'Experienced skater, gnarly spots' }, all: { label: 'All Levels', emoji: '🛹', description: 'Suitable for everyone' }, } as const; // Tour spot types (what kind of spots the tour covers) export const TOUR_SPOT_TYPES = { street: { label: 'Street', emoji: '🛣️' }, park: { label: 'Park', emoji: '🏟️' }, diy: { label: 'DIY', emoji: '🔨' }, transition: { label: 'Transition', emoji: '🌊' }, hidden: { label: 'Hidden Gems', emoji: '💎' }, } as const; // Tour categories export const TOUR_CATEGORIES = { local: { label: 'Local Tour', emoji: '🏠', color: '#06b6d4', // Cyan description: 'Show hidden spots in your city', durationLabel: '2-8 hours', }, national: { label: 'National Tour', emoji: '🚐', color: '#f97316', // Orange description: 'Multi-city road trips across the country', durationLabel: '2-14 days', }, international: { label: 'International Tour', emoji: '✈️', color: '#a855f7', // Purple description: 'Cross-border adventures across multiple countries', durationLabel: '1-8 weeks', }, destruction: { label: 'World Destruction', emoji: '💀', color: '#ef4444', // Red description: 'KOTR-style global team competition', durationLabel: '1-12 months', }, } as const; export type TourCategory = keyof typeof TOUR_CATEGORIES; // Destruction Tour Challenge interface export interface DestructionChallenge { id: string; title: string; description?: string; points: number; isSecret?: boolean; // Hidden until competition starts (all challenges secret by default) revealDate?: number; // Unix timestamp when challenge becomes visible (defaults to tour start date) } // Destruction Tour Team interface export interface DestructionTeam { id: string; name: string; members: string[]; // pubkeys of team members captainPubkey: string; points: number; completedChallenges: string[]; // Challenge IDs } // Destruction Tour Submission (clip/proof) export interface DestructionSubmission { id: string; teamId: string; challengeId: string; submitterPubkey: string; videoUrl?: string; // External link (YouTube, Instagram, etc.) blossomUrl?: string; // Blossom-hosted video description?: string; submittedAt: number; status: 'pending' | 'approved' | 'rejected'; pointsAwarded?: number; } // Gear Categories export const GEAR_CATEGORIES = { deck: 'Deck', trucks: 'Trucks', wheels: 'Wheels', bearings: 'Bearings', shoes: 'Skate Shoes', clothing: 'Clothing', protective: 'Protective Gear', stickers: 'Stickers', wax: 'Wax', tools: 'Tools', griptape: 'Griptape', rubbrick: 'Rub Brick', } as const; // Gear Conditions export const GEAR_CONDITIONS = { new: { label: 'New', emoji: '🆕', description: 'Never used, still in packaging' }, like_new: { label: 'Like New', emoji: '👍', description: 'Barely used, excellent condition' }, good: { label: 'Good', emoji: '✅', description: 'Normal wear, functions perfectly' }, well_used: { label: 'Well Used', emoji: '⚠️', description: 'Shows wear but still works' }, } as const; // Price Types export const PRICE_TYPES = { fixed: { label: 'Fixed Price', description: 'Set price in USD or sats' }, free: { label: 'Free', description: 'Giving away' }, trade: { label: 'Trade', description: 'Looking to swap' }, offer: { label: 'Make Offer', description: 'Price negotiable' }, } as const; // Listing Types export const LISTING_TYPES = { selling: { label: 'Selling', emoji: '💰', description: 'Listing gear for sale' }, wtb: { label: 'Want to Buy', emoji: '🔍', description: 'Looking for specific gear' }, } as const; // Size Options by Category export const SIZE_OPTIONS: Record = { deck: ['7.5"', '7.75"', '8.0"', '8.125"', '8.25"', '8.38"', '8.5"', '8.75"', '9.0"+'], trucks: ['129', '139', '144', '149', '159'], wheels: ['50mm', '52mm', '54mm', '56mm', '58mm', '60mm'], shoes: ['US 6', 'US 6.5', 'US 7', 'US 7.5', 'US 8', 'US 8.5', 'US 9', 'US 9.5', 'US 10', 'US 10.5', 'US 11', 'US 11.5', 'US 12', 'US 13', 'US 14'], clothing: ['XS', 'S', 'M', 'L', 'XL', 'XXL'], }; // Truck Heights export const TRUCK_HEIGHTS = ['Low', 'Mid', 'High', 'Inverted']; // Wheel Hardness export const WHEEL_HARDNESS = ['78a', '87a', '92a', '97a', '99a', '101a']; // Category-specific required fields for validation export const CATEGORY_REQUIRED_FIELDS: Record = { deck: { size: true, condition: true }, trucks: { size: true, condition: true }, wheels: { size: true, condition: true }, bearings: { condition: true }, shoes: { size: true, condition: true }, clothing: { size: true, condition: true }, protective: { size: false, condition: true }, stickers: { size: false, condition: false }, wax: { size: false, condition: false }, tools: { size: false, condition: true }, griptape: { size: false, condition: true }, rubbrick: { size: false, condition: true }, }; // Report reasons for NIP-56 export const LISTING_REPORT_REASONS = { category_mismatch: { label: 'Wrong Category', description: 'Item is listed in the wrong category' }, misleading_description: { label: 'Misleading Description', description: 'Description does not match the actual item' }, fake_photos: { label: 'Fake/Stolen Photos', description: 'Photos are stock images or stolen from elsewhere' }, scam: { label: 'Suspected Scam', description: 'This listing appears to be a scam' }, spam: { label: 'Spam/Duplicate', description: 'Spam or duplicate listing' }, inappropriate: { label: 'Inappropriate Content', description: 'Contains inappropriate or offensive content' }, counterfeit: { label: 'Counterfeit Goods', description: 'Item appears to be fake or counterfeit' }, } as const; // Expiration Options (in days) export const EXPIRATION_OPTIONS = [ { value: 7, label: '7 days' }, { value: 14, label: '14 days' }, { value: 30, label: '30 days' }, ]; // Currency Options (ISO 4217 codes + Bitcoin) export const CURRENCY_OPTIONS = [ // Crypto { code: 'sats', label: 'sats', symbol: '₿', region: 'Bitcoin' }, { code: 'BTC', label: 'BTC', symbol: '₿', region: 'Bitcoin' }, // Major Fiat - Americas { code: 'USD', label: 'USD', symbol: '$', region: 'United States' }, { code: 'CAD', label: 'CAD', symbol: 'C$', region: 'Canada' }, { code: 'MXN', label: 'MXN', symbol: '$', region: 'Mexico' }, { code: 'BRL', label: 'BRL', symbol: 'R$', region: 'Brazil' }, { code: 'ARS', label: 'ARS', symbol: '$', region: 'Argentina' }, { code: 'CLP', label: 'CLP', symbol: '$', region: 'Chile' }, { code: 'COP', label: 'COP', symbol: '$', region: 'Colombia' }, { code: 'PEN', label: 'PEN', symbol: 'S/', region: 'Peru' }, // Major Fiat - Europe { code: 'EUR', label: 'EUR', symbol: '€', region: 'Eurozone' }, { code: 'GBP', label: 'GBP', symbol: '£', region: 'United Kingdom' }, { code: 'CHF', label: 'CHF', symbol: 'Fr', region: 'Switzerland' }, { code: 'SEK', label: 'SEK', symbol: 'kr', region: 'Sweden' }, { code: 'NOK', label: 'NOK', symbol: 'kr', region: 'Norway' }, { code: 'DKK', label: 'DKK', symbol: 'kr', region: 'Denmark' }, { code: 'PLN', label: 'PLN', symbol: 'zł', region: 'Poland' }, { code: 'CZK', label: 'CZK', symbol: 'Kč', region: 'Czech Republic' }, { code: 'HUF', label: 'HUF', symbol: 'Ft', region: 'Hungary' }, { code: 'RON', label: 'RON', symbol: 'lei', region: 'Romania' }, // Major Fiat - Asia Pacific { code: 'JPY', label: 'JPY', symbol: '¥', region: 'Japan' }, { code: 'CNY', label: 'CNY', symbol: '¥', region: 'China' }, { code: 'KRW', label: 'KRW', symbol: '₩', region: 'South Korea' }, { code: 'AUD', label: 'AUD', symbol: 'A$', region: 'Australia' }, { code: 'NZD', label: 'NZD', symbol: 'NZ$', region: 'New Zealand' }, { code: 'SGD', label: 'SGD', symbol: 'S$', region: 'Singapore' }, { code: 'HKD', label: 'HKD', symbol: 'HK$', region: 'Hong Kong' }, { code: 'TWD', label: 'TWD', symbol: 'NT$', region: 'Taiwan' }, { code: 'THB', label: 'THB', symbol: '฿', region: 'Thailand' }, { code: 'PHP', label: 'PHP', symbol: '₱', region: 'Philippines' }, { code: 'IDR', label: 'IDR', symbol: 'Rp', region: 'Indonesia' }, { code: 'MYR', label: 'MYR', symbol: 'RM', region: 'Malaysia' }, { code: 'INR', label: 'INR', symbol: '₹', region: 'India' }, // Middle East & Africa { code: 'AED', label: 'AED', symbol: 'د.إ', region: 'UAE' }, { code: 'SAR', label: 'SAR', symbol: '﷼', region: 'Saudi Arabia' }, { code: 'ILS', label: 'ILS', symbol: '₪', region: 'Israel' }, { code: 'TRY', label: 'TRY', symbol: '₺', region: 'Turkey' }, { code: 'ZAR', label: 'ZAR', symbol: 'R', region: 'South Africa' }, { code: 'NGN', label: 'NGN', symbol: '₦', region: 'Nigeria' }, { code: 'EGP', label: 'EGP', symbol: 'E£', region: 'Egypt' }, ]; // Helper to get currency symbol from code export function getCurrencySymbol(code: string): string { const currency = CURRENCY_OPTIONS.find(c => c.code === code); return currency?.symbol || code; } // Helper to format price with correct currency symbol export function formatPrice(price: { amount: string; currency: string } | undefined, priceType?: string): string { if (!price) { if (priceType === 'free') return 'FREE'; if (priceType === 'trade') return 'Trade'; if (priceType === 'offer') return 'Make Offer'; return 'Offer'; } const symbol = getCurrencySymbol(price.currency); // For Bitcoin/sats, show symbol before amount if (price.currency === 'sats' || price.currency === 'BTC') { return `${symbol}${price.amount}`; } // For most currencies, show symbol before amount return `${symbol}${price.amount} ${price.currency}`; } // NIP-99 Marketplace (Gear Swap) export interface MarketplaceItem { id: string; pubkey: string; identifier: string; title: string; description: string; summary?: string; price?: { amount: string; currency: string; frequency?: string; }; priceType?: 'fixed' | 'free' | 'trade' | 'offer'; condition?: 'new' | 'like_new' | 'good' | 'well_used'; listingType?: 'selling' | 'wtb'; size?: string; expiration?: number; // Unix timestamp images: string[]; location?: string; geohash?: string; category: string[]; status?: string; // 'active' | 'sold' | 'pending' spotReference?: string; // 'a' tag pointing to spot event availability?: string; // When seller is at the spot createdAt: number; publishedAt?: number; event: NostrEvent; } // NIP-51 Spot Collections (Bookmark sets) export interface SpotCollection { id: string; pubkey: string; identifier: string; title: string; description: string; image?: string; spotRefs: string[]; // Array of "a" tag references: "30818:pubkey:d-tag" createdAt: number; event: NostrEvent; } export function parseSpotCollection(event: NostrEvent): SpotCollection | null { if (event.kind !== SPOT_COLLECTION_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; // Must have skatespots tag to be a skate spot collection const hasSkatespotsTag = event.tags.some( ([name, value]) => name === 't' && value === 'skatespots' ); if (!d || !title || !hasSkatespotsTag) return null; const description = event.tags.find(([name]) => name === 'description')?.[1] || event.content; const image = event.tags.find(([name]) => name === 'image')?.[1]; // Get all "a" tags that reference spots (kind 30818) const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith(`${SKATE_SPOT_KIND}:`)) .map(([, value]) => value); return { id: event.id, pubkey: event.pubkey, identifier: d, title, description, image, spotRefs, createdAt: event.created_at, event, }; } export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null { if (event.kind !== MARKETPLACE_ITEM_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; if (!d || !title) return null; const summary = event.tags.find(([name]) => name === 'summary')?.[1]; const location = event.tags.find(([name]) => name === 'location')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const status = event.tags.find(([name]) => name === 'status')?.[1]; const availability = event.tags.find(([name]) => name === 'available')?.[1]; const spotReference = event.tags.find(([name]) => name === 'a')?.[1]; const publishedAtTag = event.tags.find(([name]) => name === 'published_at')?.[1]; // Parse new fields const priceType = event.tags.find(([name]) => name === 'price_type')?.[1] as MarketplaceItem['priceType']; const condition = event.tags.find(([name]) => name === 'condition')?.[1] as MarketplaceItem['condition']; const listingType = event.tags.find(([name]) => name === 'listing_type')?.[1] as MarketplaceItem['listingType']; const size = event.tags.find(([name]) => name === 'size')?.[1]; const expirationTag = event.tags.find(([name]) => name === 'expiration')?.[1]; const expiration = expirationTag ? parseInt(expirationTag) : undefined; // Parse price tag: ["price", "40", "USD", "optional-frequency"] const priceTag = event.tags.find(([name]) => name === 'price'); const price = priceTag ? { amount: priceTag[1] || '0', currency: priceTag[2] || 'USD', frequency: priceTag[3], } : undefined; // Parse images (NIP-58 format) const images = event.tags .filter(([name]) => name === 'image') .map(([, url]) => url); // Parse categories/tags const category = event.tags .filter(([name]) => name === 't') .map(([, value]) => value); return { id: event.id, pubkey: event.pubkey, identifier: d, title, description: event.content, summary, price, priceType, condition, listingType, size, expiration, images, location, geohash, category, status, spotReference, availability, createdAt: event.created_at, publishedAt: publishedAtTag ? parseInt(publishedAtTag) : undefined, event, }; }