SkateSpots/src/lib/types.ts
shakespeare.diy 4e227f0367 feat: License page, Video Editors in Lab, Buy/Sell Gear, Stash locations, Supporters page
1. /license page
   - New LicensePage with "YOU ARE FREE TO SKATEBOARD" license text
   - Copyright 2025-∞ with neon THUG2 styling
   - Footer: "License" and "Supporters" links added next to OpenSats

2. /lab — Video Editor category
   - New SKATE_EDITOR_KIND (35003) in types.ts
   - EDITOR_SERVICE_LABELS/DESCRIPTIONS (8 edit types)
   - EDITOR_SKILL_LABELS/DESCRIPTIONS (8 software/hardware skills)
   - SkateEditor interface + parseSkateEditor() parser
   - useLabProfiles updated to query kind 35003 + 'skateeditor' tag
   - LabPage: new "Video Editors" tab (purple, Scissors icon)
   - LabPage: "Offer Editing" button in header
   - BecomeEditorPage: full form with edit types, skills, hardware,
     software, portfolio, contact, location, wallet requirement
   - Route /become-editor registered in AppRouter

3. /map Quick Actions
   - "Sell Gear" → "Buy/Sell Gear" with ArrowRightLeft icon
   - Button now links to /marketplace (browse + sell)

4. /stash — Storage locations
   - StashItem.storageLocation field: home/warehouse/friends/parents/wardrobe/other
   - AddItemDialog: 6-button grid to tag where gear is stored
   - StashItemCard: shows location badge on each card
   - Filter bar: pill filter chips to filter stash by location
   - storageLocation saved/restored via onAdd handler

5. /supporters page
   - btcmap.org-style supporters page
   - Live Geyser GraphQL leaderboard (skatespots project)
   - Avatar grid with sat amounts, anon fallbacks
   - Mission section + "Send Sats" section (Lightning address + Geyser)
   - Added to footer and desktop "More" dropdown and mobile menu

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-16 11:05:03 +07:00

1354 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string> = {
// 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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string[]> = {
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<string, { size?: boolean; condition?: boolean }> = {
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,
};
}