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>
1354 lines
47 KiB
TypeScript
1354 lines
47 KiB
TypeScript
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,
|
||
};
|
||
}
|