SkateSpots/src/lib/types.ts
shakespeare.diy 9cb8d1181d Add individual/collective entity type + NIP-32 membership attestation to The Lab
Step 1 of the collective plan (shared primitive on existing Lab categories):
- types.ts: EntityType + entity/members fields on coach/filmer/editor/
  photographer interfaces & parsers; membership label namespace constants +
  parseMemberPubkeys / parseEntityType helpers
- useCollectiveMembers: cross-references claimed `p` tags against members'
  own NIP-32 (kind 1985) attestations -> confirmed / pending status
- useConfirmMembership: publishes a member's self-signed membership label
- EntityTypeField: individual/collective toggle + member npub picker (per-
  category accent) wired into all 4 Become-* forms
- CollectiveMembers: sidebar card with confirmed/pending trust chips
- ConfirmMembershipCard: one-tap confirm shown only to listed pending members
- CoachDetailPage: collective badge, members card, confirm-membership prompt
- Lab cards: compact collective badge with member count
- NIP.md: document entity tag + collective membership attestation flow

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 13:18:18 +07:00

1667 lines
60 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';
import ngeohash from 'ngeohash';
// 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';
// ─── Validation helpers ───────────────────────────────────────────────────────
/** Max lengths for user-supplied text fields */
const MAX_TITLE_LEN = 100;
const MAX_DESCRIPTION_LEN = 3000;
const MAX_LOCATION_LEN = 200;
const MIN_TITLE_LEN = 2;
const MIN_GEOHASH_LEN = 4; // below 4 chars = ~2500km precision, useless for a spot
const MAX_GEOHASH_LEN = 12;
/**
* Validates a geohash string.
* - Must be 4–12 characters
* - Must decode to real Earth coordinates (lat ±90, lng ±180)
* - Must not be exactly 0,0 (null island — common bot artifact)
* VPN/Tor users are unaffected: the geohash comes from the map pin, not the user's IP.
*/
function isValidGeohash(geohash: string | undefined): boolean {
if (!geohash) return false;
if (geohash.length < MIN_GEOHASH_LEN || geohash.length > MAX_GEOHASH_LEN) return false;
try {
const { latitude, longitude } = ngeohash.decode(geohash);
if (latitude < -90 || latitude > 90) return false;
if (longitude < -180 || longitude > 180) return false;
// Reject "null island" (0°N 0°E — classic bot/placeholder artifact)
if (latitude === 0 && longitude === 0) return false;
return true;
} catch {
return false;
}
}
function clampStr(s: string | undefined, max: number): string | undefined {
if (!s) return s;
return s.slice(0, max);
}
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 (video) profiles
export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
export const SKATE_PHOTOGRAPHER_KIND = 35004; // Skate photographer 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
/**
* Lab provider entity type.
* - `individual`: a single person (default).
* - `collective`: a multi-person entity (crew, collective, or company). "Company"
* is presented as cosmetic sub-styling of a collective, not a distinct trust tier.
*/
export type EntityType = 'individual' | 'collective';
export function parseEntityType(value: string | undefined | null): EntityType {
return value === 'collective' ? 'collective' : 'individual';
}
/**
* NIP-32 label namespace used for collective membership attestations.
* A member proves membership by publishing a kind 1985 label event with:
* ["L", "org.skatespots.membership"]
* ["l", "member", "org.skatespots.membership"]
* ["a", "<kind>:<founderPubkey>:<d-tag>"] // the collective being joined
*/
export const MEMBERSHIP_LABEL_NAMESPACE = 'org.skatespots.membership';
export const MEMBERSHIP_LABEL_VALUE = 'member';
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
export function parseMemberPubkeys(event: NostrEvent): string[] {
return event.tags
.filter(([name, value]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? ''))
.map(([, value]) => value.toLowerCase());
}
/**
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
* static payment code (see https://github.com/shocknet/clink, kind 21001).
* We only check the bech32-style prefix and character set; the app does not
* decode or transact — it hands the code off to a compatible wallet.
*/
export function isValidNoffer(value: string | undefined | null): value is string {
if (!value) return false;
return /^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/.test(value.trim().toLowerCase());
}
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;
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
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;
// Validate title length
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
// Validate geohash — rejects fake/ocean/bot coords, VPN/Tor unaffected
if (!isValidGeohash(geohash)) return null;
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
// 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: title.slice(0, MAX_TITLE_LEN),
description: event.content.slice(0, MAX_DESCRIPTION_LEN),
geohash,
location,
images,
category: category || legacyCategory,
tags,
types,
accessType,
contactInfo: clampStr(contactInfo, 200),
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;
// Validate name length
if (name.trim().length < MIN_TITLE_LEN || name.length > MAX_TITLE_LEN) return null;
// Validate geohash
if (!isValidGeohash(geohash)) return null;
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
// 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];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
return {
id: event.id,
pubkey: event.pubkey,
identifier: d,
name: name.slice(0, MAX_TITLE_LEN),
description: event.content.slice(0, MAX_DESCRIPTION_LEN),
geohash,
location,
images,
phone,
email,
website,
instagram,
hours,
noffer,
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;
// Validate title length
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
const end = event.tags.find(([name]) => name === 'end')?.[1];
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
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: title.slice(0, MAX_TITLE_LEN),
description: event.content.slice(0, MAX_DESCRIPTION_LEN),
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',
followlines: 'Follow / Line Filming',
lines: 'Line & B-Roll Filming',
};
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',
followlines: 'Fisheye follow-cam for lines',
lines: 'Long-lens lines and cinematic b-roll',
};
// Filming skills/equipment (video-focused)
export const FILMING_SKILL_LABELS: Record<string, string> = {
vx1000: 'VX1000',
hvx: 'HVX / HPX',
cinema: 'Cinema Camera',
dslr: 'DSLR / Mirrorless',
actioncam: 'GoPro / Action Cam',
phone: 'Phone Filming',
drone: 'Drone / Aerial',
gimbal: 'Gimbal / Stabilizer',
fisheye: 'Fisheye Lens',
longlens: 'Long Lens',
'4k': '4K / High Frame Rate',
editing: 'Video Editing',
color: 'Color Grading',
};
export const FILMING_SKILL_DESCRIPTIONS: Record<string, string> = {
vx1000: 'Classic VX1000 skateboarding aesthetic',
hvx: 'Panasonic HVX/HPX standard-def / HD look',
cinema: 'Cinema camera (FX3, C-series, RED, BMPCC)',
dslr: 'High quality DSLR or mirrorless camera',
actioncam: 'GoPro / action cam POV and mounts',
phone: 'Mobile filming with modern smartphones',
drone: 'Aerial and drone footage',
gimbal: 'Stabilized gimbal movement',
fisheye: 'Wide-angle fisheye lens shots',
longlens: 'Long lens for detailed shots',
'4k': '4K resolution and high frame rate slow-mo',
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',
};
// ─── Photographer taxonomy ──────────────────────────────────────────────────
// Skate photography is a distinct craft from filming — it's shot-, light-,
// and print-oriented. These maps power The Lab's Photographers category.
// What a photographer offers (deliverables / shoot types)
export const PHOTO_SERVICE_LABELS: Record<string, string> = {
action: 'Action / Trick Shots',
sequence: 'Sequence Shots',
portraits: 'Portraits',
editorial: 'Editorial / Magazine',
product: 'Product / Shop Photography',
events: 'Event Coverage',
lifestyle: 'Lifestyle / Documentary',
prints: 'Prints & Zines',
};
export const PHOTO_SERVICE_DESCRIPTIONS: Record<string, string> = {
action: 'Peak-action stills of tricks and lines',
sequence: 'Multi-frame trick sequences',
portraits: 'Rider portraits and team shots',
editorial: 'Magazine-ready editorial spreads',
product: 'Deck, gear and shop product photography',
events: 'Comp, jam and premiere coverage',
lifestyle: 'Behind-the-scenes and culture documentary',
prints: 'Physical prints, zines and photo books',
};
// Camera bodies / formats
export const PHOTO_CAMERA_LABELS: Record<string, string> = {
dslr: 'DSLR',
mirrorless: 'Mirrorless',
medium: 'Medium Format',
film35: '35mm Film',
filmmedium: 'Medium Format Film',
point: 'Point & Shoot',
phone: 'Phone',
};
// Lenses
export const PHOTO_LENS_LABELS: Record<string, string> = {
fisheye: 'Fisheye',
wide: 'Wide Angle',
standard: 'Standard / Prime',
telephoto: 'Telephoto / Long Lens',
macro: 'Macro',
zoom: 'Zoom',
};
// Lighting gear
export const PHOTO_LIGHTING_LABELS: Record<string, string> = {
strobes: 'Studio Strobes',
speedlights: 'Speedlights / Flashes',
profoto: 'Profoto',
elinchrom: 'Elinchrom',
godox: 'Godox',
triggers: 'Remote Triggers',
continuous: 'Continuous / LED',
reflectors: 'Reflectors / Diffusers',
naturallight: 'Natural Light Only',
};
// Support / accessories
export const PHOTO_SUPPORT_LABELS: Record<string, string> = {
tripod: 'Tripod',
monopod: 'Monopod',
lightstands: 'Light Stands',
gimbal: 'Gimbal',
bag: 'Mobile Rig / Bag',
laptop: 'On-site Tethering',
};
// Shooting style
export const PHOTO_STYLE_LABELS: Record<string, string> = {
flashfisheye: 'Flash Fisheye',
natural: 'Natural Light',
bw: 'Black & White',
film: 'Film Aesthetic',
longlens: 'Long Lens',
studio: 'Studio / Lit',
street: 'Street / Documentary',
};
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;
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
entityType: EntityType; // individual (default) or collective
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
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;
portfolio?: string; // Reel / portfolio link (Vimeo, YouTube, IG)
social?: string; // Social media handle or link
gear?: string; // Freeform "what's in the bag" (cameras, lenses, rigs)
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
entityType: EntityType; // individual (default) or collective
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
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"
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
entityType: EntityType; // individual (default) or collective
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
createdAt: number;
event: NostrEvent;
}
export interface SkatePhotographer {
id: string;
pubkey: string;
identifier: string;
title: string;
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // Combined services + camera/lens/lighting/support/style tags
experience?: string; // Years of experience
rate?: string;
phone?: string;
email?: string;
website?: string;
portfolio?: string; // Portfolio link (Instagram, personal site, etc.)
social?: string;
gear?: string; // Freeform "what's in the bag" (bodies, lenses, lights)
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
entityType: EntityType; // individual (default) or collective
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
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;
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
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];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]);
const members = parseMemberPubkeys(event);
// 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: title.slice(0, MAX_TITLE_LEN),
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
location,
geohash,
image,
skills,
experience,
rate,
phone,
email,
website,
instagram,
noffer,
entityType,
members,
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;
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
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 portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]);
const members = parseMemberPubkeys(event);
// 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: title.slice(0, MAX_TITLE_LEN),
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
location,
geohash,
image,
skills,
experience,
rate,
phone,
email,
website,
portfolio,
social,
gear,
noffer,
entityType,
members,
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;
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
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];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]);
const members = parseMemberPubkeys(event);
return {
id: event.id,
pubkey: event.pubkey,
identifier: d,
title: title.slice(0, MAX_TITLE_LEN),
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
location,
geohash,
image,
skills,
experience,
rate,
phone,
email,
website,
portfolio,
social,
hardware,
software,
noffer,
entityType,
members,
createdAt: event.created_at,
event,
};
}
export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | null {
if (event.kind !== SKATE_PHOTOGRAPHER_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;
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
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 !== 'skatephotographer').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 gear = event.tags.find(([name]) => name === 'gear')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]);
const members = parseMemberPubkeys(event);
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: title.slice(0, MAX_TITLE_LEN),
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
location,
geohash,
image,
skills,
experience,
rate,
phone,
email,
website,
portfolio,
social,
gear,
noffer,
entityType,
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
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,
};
}