import type { NostrEvent } from '@nostrify/nostrify'; import ngeohash from 'ngeohash'; import { parseCrewVideo } from '@/lib/videoEmbed'; // 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_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity 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 SKATE_BUILDER_KIND = 35005; // Skate ramp / park builder profiles export const SKATE_CREW_KIND = 35006; // Skate crews (community identity, map-anchored) export const SKATE_ARTIST_KIND = 35007; // Designer / artist profiles (board graphics, logos, posters, merch) export const SKATE_GRAFFITI_KIND = 35008; // Graffiti / mural artist profiles export const SKATE_TATTOO_KIND = 35009; // Tattoo artist profiles export const SKATE_TRAINER_KIND = 35010; // Physical trainer / physiotherapist profiles export const SKATE_MUSICIAN_KIND = 35011; // Musician / band profiles (skaters who make music) export const SKATE_JUDGE_KIND = 35012; // Skate judges (event officials) 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 const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). SkateSpots-native. export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membership receipt (self-signed, references the zap payment). SkateSpots-native. export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native. export const CREW_INVITE_KIND = 36229; // Addressable crew invitation — founder/admin invites a specific npub to a crew. SkateSpots-native. export const CREW_JOIN_REQUEST_KIND = 31969; // Addressable crew join-request — a skater asks to join a crew (notifiable). SkateSpots-native. /** * 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", "::"] // the collective being joined */ export const MEMBERSHIP_LABEL_NAMESPACE = 'org.skatespots.membership'; export const MEMBERSHIP_LABEL_VALUE = 'member'; /** * Member visibility — an OPT-IN privacy choice a member makes on their OWN * membership attestation (kind 1985), so a member can be private even inside a * public crew. Stored as `["visibility", ]` on the member's own label. * * - `public` (default / absent): the member shows everywhere — public crew * page, member counts, crew-mate lists. * - `crew`: the member is hidden from NON-members. To outsiders they render as * an anonymous "hidden member" slot; confirmed crew-mates (and the founder) * still see them normally. This is DISCOVERY privacy: the underlying `p` tag * on the founder's crew event is still public on relays (that data belongs * to the founder, not the member, so it can't be encrypted away) — but the * app never surfaces the member's identity to non-members, and they are * excluded from public-facing crew-mate discovery. * * We deliberately DON'T offer a "cryptographically hidden membership" tier: the * membership claim lives in the founder's event and cannot be encrypted by the * member, so promising crypto-privacy there would be misleading. `crew` is * honest app-level privacy; anyone wanting true anonymity should simply not * confirm membership publicly. */ export type MemberVisibility = 'public' | 'crew'; export const MEMBER_VISIBILITY_LABELS: Record = { public: 'Public', crew: 'Crew only', }; export const MEMBER_VISIBILITY_DESCRIPTIONS: Record = { public: 'Anyone can see you in this crew — on the public crew page and in crew-mate discovery.', crew: "Only confirmed crew-mates and the founder see you here. To everyone else you appear as an anonymous “hidden member”. Note: this is app-level privacy — your membership tag still exists publicly on relays, it's just never shown to outsiders.", }; /** Parse a member visibility value, defaulting to 'public'. */ export function parseMemberVisibility(v?: string): MemberVisibility { return v === 'crew' ? 'crew' : 'public'; } /** * Lab vouches (NIP-32) — the community's anti-fraud lever. On an open protocol * anyone can publish a profile claiming to be a physio, artist, etc., so instead * of trying (and failing) to gatekeep, SkateSpots lets skaters CO-SIGN providers * they personally vouch for. A voucher publishes a kind-1985 label with their * OWN key referencing the provider's addressable coordinate. Vouches are counted * client-side and, crucially, weighted by the viewer's web-of-trust so a ring of * fake accounts vouching for each other carries no weight with real skaters. */ export const VOUCH_LABEL_NAMESPACE = 'org.skatespots.vouch'; export const VOUCH_LABEL_VALUE = 'vouch'; /** * Crew feed/chat moderation (NIP-32). * * A founder or delegated admin can hide a crew-feed post by publishing a kind * 1985 label with their OWN key: * ["L", "org.skatespots.moderation"] * ["l", "hidden", "org.skatespots.moderation"] * ["e", ] // the moderated post * ["a", ] // scopes moderation to this crew * Clients hide the post only if the label's author is the crew founder or a * listed admin — so moderation power can't be forged by outsiders. */ export const MODERATION_LABEL_NAMESPACE = 'org.skatespots.moderation'; export const MODERATION_LABEL_HIDDEN = 'hidden'; /** 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()); } /** * Extract the admin pubkeys a collective delegates powers to. * * Admins are ordinary members whose `p` tag carries an `"admin"` role marker in * the 4th position: `["p", , , "admin"]`. Using a marker on * the standard `p` tag keeps admins visible to any client that reads members, * while letting SkateSpots grant them elevated *actions* (invite, moderate). * * Note: admins are delegated *powers*, not edit rights — only the founder (the * event author) can overwrite the addressable crew profile. Admins act with * their own keys. */ export function parseAdminPubkeys(event: NostrEvent): string[] { return event.tags .filter( ([name, value, , marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin', ) .map(([, value]) => value.toLowerCase()); } /** All Lab provider kinds (The Lab service profiles). */ export const LAB_KINDS = [35001, 35002, 35003, 35004, 35005] as const; /** * "Pro" / featured status for Lab profiles and events. * * When a user pays the optional Pro/featured listing fee, the published event * carries a `["featured", ""]` tag. This is *self-attested* * (the app records payment and writes the tag) rather than cryptographically * verified against a real Lightning payment — the same trust level the rest of * the platform-fee flow already uses. A future enhancement could verify against * NIP-57 zap receipts to the platform Lightning address. */ export function parseFeaturedUntil(event: NostrEvent): number | undefined { const raw = event.tags.find(([name]) => name === 'featured')?.[1]; if (!raw) return undefined; const ts = parseInt(raw, 10); if (isNaN(ts) || ts <= 0) return undefined; return ts; // unix seconds } /** Is a `featuredUntil` timestamp (unix seconds) still active right now? */ export function isFeaturedActive(featuredUntil: number | undefined): boolean { if (!featuredUntil) return false; return featuredUntil * 1000 > Date.now(); } /** * A parsed addressable coordinate: `::`. */ export interface AddrCoordinate { kind: number; pubkey: string; identifier: string; /** The raw `kind:pubkey:d` string. */ coord: string; } /** Parse an `a`-tag coordinate string (`kind:pubkey:d`) into its parts. */ export function parseAddrCoord(value: string | undefined | null): AddrCoordinate | null { if (!value) return null; const parts = value.split(':'); if (parts.length < 3) return null; const kind = parseInt(parts[0], 10); const pubkey = parts[1]; const identifier = parts.slice(2).join(':'); if (isNaN(kind) || !/^[0-9a-f]{64}$/i.test(pubkey) || !identifier) return null; return { kind, pubkey: pubkey.toLowerCase(), identifier, coord: `${kind}:${pubkey.toLowerCase()}:${identifier}` }; } /** * Extract the crew this Lab profile links to (its `a` tag pointing at a * kind-35006 crew coordinate). Only the first valid one is used. */ export function parseCrewRef(event: NostrEvent): string | undefined { for (const [name, value] of event.tags) { if (name !== 'a') continue; const c = parseAddrCoord(value); if (c && c.kind === SKATE_CREW_KIND) return c.coord; } return undefined; } /** * Extract the Lab profile coordinates a crew links to (its `a` tags pointing at * any Lab kind). */ export function parseLabRefs(event: NostrEvent): string[] { const refs: string[] = []; for (const [name, value] of event.tags) { if (name !== 'a') continue; const c = parseAddrCoord(value); if (c && (LAB_KINDS as readonly number[]).includes(c.kind)) refs.push(c.coord); } return refs; } // ─── Crew invitations (kind 36229) ───────────────────────────────────────── // // A structured, addressable invite from a crew's founder/admin to a specific // person's pubkey. Addressable so re-inviting the same person replaces the old // invite (one live invite per crew+invitee). The invite itself is public and // carries nothing sensitive ("come join") — actual membership stays unforgeable // via the founder's `p` tag + the invitee's own NIP-32 attestation. // // kind: 36229 // content: optional personal note from the inviter // tags: // ["d", ":"] // unique per crew+invitee // ["p", ""] // recipient — queryable by them // ["a", ""] // the crew (35006:pubkey:d) // ["crew_name", ""] // denormalized for notification UI // ["role", "member" | "admin"] // proposed role (default member) // ["alt", "Crew invite: …"] export interface CrewInvite { id: string; inviter: string; // pubkey of founder/admin who sent it invitee: string; // pubkey being invited crewCoord: string; // 35006:pubkey:d crewName: string; role: 'member' | 'admin'; note: string; // optional personal message createdAt: number; event: NostrEvent; } export function parseCrewInvite(event: NostrEvent): CrewInvite | null { if (event.kind !== CREW_INVITE_KIND) return null; const invitee = event.tags.find(([n]) => n === 'p')?.[1]; const crewCoord = event.tags.find(([n]) => n === 'a')?.[1]; if (!invitee || !crewCoord) return null; const c = parseAddrCoord(crewCoord); if (!c || c.kind !== SKATE_CREW_KIND) return null; const role = event.tags.find(([n]) => n === 'role')?.[1] === 'admin' ? 'admin' : 'member'; return { id: event.id, inviter: event.pubkey, invitee, crewCoord, crewName: event.tags.find(([n]) => n === 'crew_name')?.[1] ?? 'a crew', role, note: event.content.slice(0, 500), createdAt: event.created_at, event, }; } /** Build the deterministic d-tag identifier for a crew invite. */ export function crewInviteD(crewCoord: string, inviteePubkey: string): string { return `${crewCoord}:${inviteePubkey}`; } /** * A structured crew join-request (kind 31969) — a skater asks to join a crew. * Public and addressable (keyed by crew + requester, so re-requesting replaces), * `p`-tagged to the founder + admins so their clients can surface it as a * notification with a one-click "Add to crew". Carries nothing sensitive — just * an optional note. */ export interface CrewJoinRequest { id: string; requester: string; // pubkey asking to join crewCoord: string; // 35006:pubkey:d crewName: string; note: string; // optional personal message /** Founder + admin pubkeys this request is addressed to (from `p` tags). */ recipients: string[]; createdAt: number; event: NostrEvent; } export function parseCrewJoinRequest(event: NostrEvent): CrewJoinRequest | null { if (event.kind !== CREW_JOIN_REQUEST_KIND) return null; const crewCoord = event.tags.find(([n]) => n === 'a')?.[1]; if (!crewCoord) return null; const c = parseAddrCoord(crewCoord); if (!c || c.kind !== SKATE_CREW_KIND) return null; const recipients = event.tags .filter(([n, v]) => n === 'p' && /^[0-9a-f]{64}$/i.test(v ?? '')) .map(([, v]) => v.toLowerCase()); return { id: event.id, requester: event.pubkey, crewCoord, crewName: event.tags.find(([n]) => n === 'crew_name')?.[1] ?? 'a crew', note: event.content.slice(0, 500), recipients, createdAt: event.created_at, event, }; } /** Deterministic d-tag for a join-request: crew coord + requester. */ export function crewJoinRequestD(crewCoord: string, requesterPubkey: string): string { return `${crewCoord}:${requesterPubkey}`; } /** * 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()); } /** * Validate a Bitcoin Silent Payment address (BIP-352). Mainnet addresses use * the `sp1` human-readable prefix and are bech32m-encoded (~116 chars for the * standard 66-byte payload). We validate the prefix + charset + rough length; * the app does not derive outputs — it displays the address for others to pay. */ export function isValidSilentPayment(value: string | undefined | null): value is string { if (!value) return false; const v = value.trim().toLowerCase(); // sp1 (mainnet) or tsp1 (testnet), followed by bech32 charset, long payload. return /^(t?sp)1[023456789acdefghjklmnpqrstuvwxyz]{100,}$/.test(v); } /** * Validate a Lightning address (LUD-16 / lud16) — an email-like `user@domain`. */ export function isValidLightningAddress(value: string | undefined | null): value is string { if (!value) return false; return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value.trim()); } // ─── Skater profile (kind 35000) ─────────────────────────────────────────── // The app-native identity that makes a Nostr pubkey a *discoverable SkateSpots // skater*. Publishing this (opt-in) is what lets someone appear in the /skaters // directory — a plain kind-0 profile is NOT enough (that's the anti-spam wall). export type SkaterStance = 'regular' | 'goofy'; export type SkaterStyle = 'street' | 'park' | 'vert' | 'transition' | 'bowl' | 'cruising' | 'downhill' | 'freestyle' | 'ledges' | 'stairs' | 'diy'; export type SkaterLevel = 'grom' | 'intermediate' | 'advanced' | 'ripper'; export const SKATER_STANCES: Record = { regular: { label: 'Regular', emoji: '🛹' }, goofy: { label: 'Goofy', emoji: '🤙' }, }; export const SKATER_STYLES: Record = { street: { label: 'Street', emoji: '🛹' }, park: { label: 'Park', emoji: '🏞️' }, vert: { label: 'Vert', emoji: '📐' }, transition: { label: 'Transition', emoji: '🌊' }, bowl: { label: 'Bowl', emoji: '🥣' }, cruising: { label: 'Cruising', emoji: '🛴' }, downhill: { label: 'Downhill', emoji: '⛰️' }, freestyle: { label: 'Freestyle', emoji: '🕺' }, ledges: { label: 'Ledges', emoji: '🧱' }, stairs: { label: 'Stairs', emoji: '🪜' }, diy: { label: 'DIY', emoji: '🔨' }, }; export const SKATER_LEVELS: Record = { grom: { label: 'Grom', emoji: '🌱', color: '#22c55e' }, intermediate: { label: 'Intermediate', emoji: '⚡', color: '#3b82f6' }, advanced: { label: 'Advanced', emoji: '🔥', color: '#f59e0b' }, ripper: { label: 'Ripper', emoji: '👑', color: '#ff0040' }, }; /** The `t` marker tag that identifies a SkateSpots skater profile. */ export const SKATER_TAG = 'skateskater'; export interface Skater { id: string; pubkey: string; identifier: string; title: string; // display name at publish time bio: string; location?: string; geohash?: string; image?: string; stance?: SkaterStance; styles: SkaterStyle[]; // preferred terrain/disciplines level?: SkaterLevel; customLevel?: string; // free-text level when none of the presets fit yearsSkating?: string; homeSpot?: string; // freeform name, or the title of a linked spot homeSpotRef?: string; // `a` tag coordinate (`30818:pubkey:d`) when linked to a real mapped spot instagram?: string; website?: string; createdAt: number; event: NostrEvent; } export function parseSkater(event: NostrEvent): Skater | null { if (event.kind !== SKATE_SKATER_KIND) return null; // Must carry the skater marker tag — this is the discovery gate. if (!event.tags.some(([n, v]) => n === 't' && v === SKATER_TAG)) 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 tag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; const knownStyles = Object.keys(SKATER_STYLES) as SkaterStyle[]; const styles = event.tags .filter(([n, v]) => n === 't' && (knownStyles as string[]).includes(v)) .map(([, v]) => v as SkaterStyle); const stance = tag('stance') as SkaterStance | undefined; const level = tag('level') as SkaterLevel | undefined; return { id: event.id, pubkey: event.pubkey, identifier: d, title: title.slice(0, 120), bio: event.content.slice(0, 2000), location: tag('location'), geohash: tag('g'), image: tag('image'), stance: stance && SKATER_STANCES[stance] ? stance : undefined, styles, level: level && SKATER_LEVELS[level] ? level : undefined, customLevel: tag('level_custom')?.slice(0, 40), yearsSkating: tag('years'), homeSpot: tag('home_spot'), homeSpotRef: event.tags.find(([n, v]) => n === 'a' && v?.startsWith(`${SKATE_SPOT_KIND}:`))?.[1], instagram: tag('instagram'), website: tag('website'), createdAt: event.created_at, event, }; } 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[]; featuredUntil?: number; // Featured event listing expiry (unix seconds), if paid 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, }; } // ─── Spot Status Reports (kind 2259) ────────────────────────────────────────── /** * Possible physical states of a spot, reported by the community. * Ordered roughly best → worst for display purposes. */ export const SPOT_STATUS_VALUES = [ 'active', // 🟢 Good to go 'bust', // 🟡 Heavy kick-out risk / security 'skate_stopped', // 🟠 Knobbed / capped but partially skateable 'closed', // ⚪ Temporarily closed (construction / event) 'gone', // 🔴 Demolished / paved over / no longer exists ] as const; export type SpotStatusValue = (typeof SPOT_STATUS_VALUES)[number]; export interface SpotStatusReport { id: string; pubkey: string; /** Coordinate of the spot this report references: `30818:pubkey:dtag` */ spotRef: string; status: SpotStatusValue; /** Optional freeform note from the reporter */ note: string; createdAt: number; event: NostrEvent; } const MAX_STATUS_NOTE_LEN = 500; /** Type guard for a valid spot status value. */ export function isSpotStatusValue(value: string): value is SpotStatusValue { return (SPOT_STATUS_VALUES as readonly string[]).includes(value); } /** * Parse a kind 2259 event into a SpotStatusReport. * Requires: * - an `a` tag referencing a skate spot (`30818:pubkey:dtag`) * - a `status` tag holding one of SPOT_STATUS_VALUES */ export function parseSpotStatusReport(event: NostrEvent): SpotStatusReport | null { if (event.kind !== SPOT_STATUS_KIND) return null; const spotRef = event.tags.find(([name]) => name === 'a')?.[1]; if (!spotRef || !spotRef.startsWith(`${SKATE_SPOT_KIND}:`)) return null; const status = event.tags.find(([name]) => name === 'status')?.[1]; if (!status || !isSpotStatusValue(status)) return null; return { id: event.id, pubkey: event.pubkey, spotRef, status, note: clampStr(event.content, MAX_STATUS_NOTE_LEN) ?? '', 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, featuredUntil: parseFeaturedUntil(event), createdAt: event.created_at, event, }; } // Main spot categories (mutually exclusive - user picks ONE) export const SPOT_CATEGORIES = { street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' }, transition: { label: 'Transition', emoji: '🌊', description: 'Curved/ramped surfaces' }, park: { label: 'Park', emoji: '🏟️', description: 'Built skateparks' }, plaza: { label: 'Plaza', emoji: '📍', description: 'Urban plazas (like MACBA, Love Park)' }, } as const; // Spot features organized by category export const SPOT_FEATURES = { // Street features wallrides: { label: 'Wallrides', emoji: '🧱', category: 'street' }, stairs: { label: 'Stairs & Handrails', emoji: '🪜', category: 'street' }, rails: { label: 'Rails', emoji: '🚧', category: 'street' }, ledges: { label: 'Ledges & Curbs', emoji: '⬜', category: 'street' }, gaps: { label: 'Gaps', emoji: '🕳️', category: 'street' }, flatground: { label: 'Flatground', emoji: '⬛', category: 'street' }, 'manual-pads': { label: 'Manual Pads', emoji: '⚖️', category: 'street' }, // Transition features 'half-pipes': { label: 'Half Pipes', emoji: '🌊', category: 'transition' }, 'quarter-pipes': { label: 'Quarter Pipes', emoji: '🌀', category: 'transition' }, 'mini-ramps': { label: 'Mini Ramps', emoji: '🎢', category: 'transition' }, bowls: { label: 'Bowls', emoji: '🥣', category: 'transition' }, vert: { label: 'Vert Ramps', emoji: '🔝', category: 'transition' }, 'mega-ramps': { label: 'Mega Ramps', emoji: '🚀', category: 'transition' }, // Park-specific features (only in Park/Plaza) hubbas: { label: 'Hubbas', emoji: '📐', category: 'park' }, 'euro-gaps': { label: 'Euro Gaps', emoji: '🔀', category: 'park' }, banks: { label: 'Banks', emoji: '📈', category: 'park' }, 'spine-ramps': { label: 'Spine Ramps', emoji: '🔃', category: 'park' }, 'roll-ins': { label: 'Roll-ins', emoji: '⬇️', category: 'park' }, hips: { label: 'Hips', emoji: '↗️', category: 'park' }, volcano: { label: 'Volcano', emoji: '🌋', category: 'park' }, pyramids: { label: 'Pyramids / Funboxes', emoji: '🔺', category: 'park' }, 'snake-runs': { label: 'Snake Runs', emoji: '🐍', category: 'park' }, 'pump-track': { label: 'Pump Track Rollers', emoji: '🔄', category: 'park' }, 'flat-bars': { label: 'Flat Bars', emoji: '➖', category: 'park' }, } as const; // Get features available for a category export function getFeaturesForCategory(category: string): Array<{ key: string; label: string; emoji: string }> { const allStreetFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'street') .map(([key, v]) => ({ key, label: v.label, emoji: v.emoji })); const allTransitionFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'transition') .map(([key, v]) => ({ key, label: v.label, emoji: v.emoji })); const allParkFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'park') .map(([key, v]) => ({ key, label: v.label, emoji: v.emoji })); switch (category) { case 'street': return allStreetFeatures; case 'transition': return allTransitionFeatures; case 'park': case 'plaza': // Parks and Plazas get ALL features return [...allStreetFeatures, ...allTransitionFeatures, ...allParkFeatures]; default: return []; } } // Get all features as flat list (for filters) export function getAllFeatures(): Array<{ key: string; label: string; emoji: string; category: string }> { return Object.entries(SPOT_FEATURES).map(([key, v]) => ({ key, label: v.label, emoji: v.emoji, category: v.category, })); } // Legacy SPOT_TYPE_LABELS for backwards compatibility (combines categories + features) export const SPOT_TYPE_LABELS: Record = { // Categories street: 'Street', transition: 'Transition', park: 'Park', plaza: 'Plaza', // Features (for display/filtering) wallrides: 'Wallrides', stairs: 'Stairs & Handrails', rails: 'Rails', ledges: 'Ledges & Curbs', gaps: 'Gaps', flatground: 'Flatground', 'manual-pads': 'Manual Pads', 'half-pipes': 'Half Pipes', 'quarter-pipes': 'Quarter Pipes', 'mini-ramps': 'Mini Ramps', bowls: 'Bowls', vert: 'Vert Ramps', 'mega-ramps': 'Mega Ramps', hubbas: 'Hubbas', 'euro-gaps': 'Euro Gaps', banks: 'Banks', 'spine-ramps': 'Spine Ramps', 'roll-ins': 'Roll-ins', hips: 'Hips', volcano: 'Volcano', pyramids: 'Pyramids / Funboxes', 'snake-runs': 'Snake Runs', 'pump-track': 'Pump Track Rollers', 'flat-bars': 'Flat Bars', // Legacy (deprecated but kept for backwards compat) diy: 'DIY', rail: 'Rail', ledge: 'Ledge', gap: 'Gap', bowl: 'Bowl', stall: 'Stall', slappy: 'Slappy', }; export const ACCESS_TYPE_INFO = { public: { emoji: '🌍', label: 'Public - Open to Everyone', color: '#ff0040' }, private: { emoji: '🔒', label: 'Private Property - Permission Needed', color: '#ff6600' }, locals: { emoji: '👥', label: 'Ask Locals First', color: '#ccff00' }, }; export const EVENT_TYPE_LABELS: Record = { contest: 'Contest', demo: 'Demo', session: 'Session', workshop: 'Workshop', premiere: 'Premiere', tradeshow: 'Trade Show', gameskate: 'Game of Skate', // Music & Culture concert: '🎸 Concert', jam: '🎵 Jam', festival: '🎪 Festival', popup: '🎯 Pop-Up', }; export const COACH_SKILL_LABELS: Record = { basics: 'Basics', ollies: 'Ollies', kickflips: 'Kickflips', grinds: 'Grinds & Slides', manuals: 'Manuals', inverts: 'Inverts', vert: 'Vert/Ramp', bowl: 'Bowl', street: 'Street', freestyle: 'Freestyle', safety: 'Safety', diy: 'DIY Skate Modules', }; export const COACH_SKILL_DESCRIPTIONS: Record = { basics: 'Pushing, stance, balance, turning', ollies: 'Ollie and variations (nollie, fakie)', kickflips: 'Kickflip, heelflip, and variations', grinds: '50-50, 5-0, boardslide, and more', manuals: 'Manual, nose manual, combos', inverts: 'Invert, handplant, eggplant, andrecht', vert: 'Half-pipe, quarter-pipe, drop-ins', bowl: 'Bowl carving, pumping, lines', street: 'Street obstacles and creativity', freestyle: 'Flatground tricks and flow', safety: 'How to fall, protective gear, injury prevention', diy: 'Building ramps, rails, ledges, and other skate obstacles', }; // Filming service types export const FILMING_SERVICE_LABELS: Record = { miniclips: 'Mini Clips', longparts: 'Long Video Parts', koth: 'King of the Road Style', documentaries: 'Documentaries', contests: 'Filming for Contests', raw: 'Raw Footage', followlines: 'Follow / Line Filming', lines: 'Line & B-Roll Filming', }; export const FILMING_SERVICE_DESCRIPTIONS: Record = { miniclips: '15-60 sec clips for social media', longparts: '3-10 min full video parts with music', koth: 'Adventure/challenge style skate videos', documentaries: 'Skate culture and story-driven content', contests: 'Professional contest and competition coverage', raw: 'Unedited multi-angle footage', 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 = { 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 = { 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 = { parts: 'Video Parts', montages: 'Montages', reels: 'Reels / Short Clips', documentaries: 'Documentaries', contest: 'Contest Edits', brand: 'Brand / Promo Videos', motion: 'Motion Graphics', colorgrade: 'Color Grading Only', }; export const EDITOR_SERVICE_DESCRIPTIONS: Record = { parts: 'Full skate video parts with soundtrack', montages: 'Compilation edits from raw footage', reels: '15–60 sec social media clips & reels', documentaries: 'Story-driven skate culture films', contest: 'Recap edits for contests and events', brand: 'Promotional content for brands and shops', motion: 'Titles, transitions, effects, and graphics', colorgrade: 'Colour correction and LUT-based grading', }; // Hardware / software skills for editors export const EDITOR_SKILL_LABELS: Record = { premiere: 'Adobe Premiere', resolve: 'DaVinci Resolve', finalcut: 'Final Cut Pro', aftereffects: 'After Effects', luts: 'Custom LUTs', vhs: 'VHS / Retro Aesthetic', '4k': '4K & RAW Workflows', sound: 'Sound Design / Mix', }; export const EDITOR_SKILL_DESCRIPTIONS: Record = { premiere: 'Adobe Premiere Pro editing', resolve: 'DaVinci Resolve editing & grading', finalcut: 'Final Cut Pro editing', aftereffects: 'Motion graphics and compositing', luts: 'Custom colour look development', vhs: 'VHS glitch, super-8, retro film looks', '4k': 'High-res RAW/LOG camera workflows', sound: 'Music sync, SFX, audio mastering', }; // ─── 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 = { 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 = { 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 = { 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 = { fisheye: 'Fisheye', wide: 'Wide Angle', standard: 'Standard / Prime', telephoto: 'Telephoto / Long Lens', macro: 'Macro', zoom: 'Zoom', }; // Lighting gear export const PHOTO_LIGHTING_LABELS: Record = { 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 = { 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 = { flashfisheye: 'Flash Fisheye', natural: 'Natural Light', bw: 'Black & White', film: 'Film Aesthetic', longlens: 'Long Lens', studio: 'Studio / Lit', street: 'Street / Documentary', }; // ----- Builder (ramp / park / DIY builders) ----- // What kind of things they build export const BUILDER_SERVICE_LABELS: Record = { diy: 'DIY Spots', concrete: 'Concrete Parks', wood: 'Wooden Ramps', bowls: 'Bowls & Pools', transitions: 'Transitions & Quarters', rails: 'Rails & Ledges', boxes: 'Manny Pads & Boxes', minis: 'Mini Ramps', pumptrack: 'Pump Tracks', foam: 'Foam Pits / Indoor', repairs: 'Repairs & Resurfacing', design: 'Design & Consulting', }; export const BUILDER_SERVICE_DESCRIPTIONS: Record = { diy: 'Community DIY spots and guerrilla builds', concrete: 'Poured-concrete skateparks and features', wood: 'Wooden ramps, launch boxes and obstacles', bowls: 'Bowls, pools and deep transition', transitions: 'Quarterpipes, banks and transition', rails: 'Steel rails, ledges and grind features', boxes: 'Manny pads, funboxes and street features', minis: 'Mini ramps and backyard setups', pumptrack: 'Pump tracks and flow lines', foam: 'Foam pits, resi and indoor training features', repairs: 'Fixing, patching and resurfacing existing spots', design: 'Plans, drawings and build consulting', }; // Materials / trade skills export const BUILDER_SKILL_LABELS: Record = { concretework: 'Concrete / Shotcrete', welding: 'Welding / Steel Fab', carpentry: 'Carpentry / Woodwork', formwork: 'Formwork & Rebar', masonry: 'Masonry / Blockwork', coping: 'Coping (pool / steel)', surfacing: 'Skatelite / Surfacing', earthworks: 'Earthworks / Excavation', }; // Scale of projects taken on export const BUILDER_SCALE_LABELS: Record = { backyard: 'Backyard / Mini', diycommunity: 'Community DIY', publicpark: 'Full Public Park', indoor: 'Indoor Facility', event: 'Event / Contest Course', }; // ----- Designer / Artist (board graphics, logos, posters, merch) ----- // What they make export const ARTIST_SERVICE_LABELS: Record = { boardart: 'Board Graphics', logos: 'Logos & Branding', posters: 'Posters & Flyers', merch: 'Merch & Apparel', zines: 'Zines & Print', illustration: 'Illustration', stickers: 'Stickers & Decals', webdesign: 'Web / Digital', typography: 'Typography / Lettering', animation: 'Animation / Motion', }; export const ARTIST_SERVICE_DESCRIPTIONS: Record = { boardart: 'Deck graphics and griptape art', logos: 'Brand identity, logos and wordmarks', posters: 'Event posters, flyers and gig art', merch: 'T-shirts, hoodies and apparel graphics', zines: 'Zines, editorial and print layout', illustration: 'Custom illustration and character work', stickers: 'Sticker packs, decals and slaps', webdesign: 'Websites, digital and UI', typography: 'Hand lettering and custom type', animation: 'Motion graphics and animation', }; // Medium / how they work export const ARTIST_MEDIUM_LABELS: Record = { digital: 'Digital / Vector', handdrawn: 'Hand-Drawn', screenprint: 'Screen Print', paint: 'Paint / Acrylic', collage: 'Collage / Mixed', photo: 'Photo / Photoshop', '3d': '3D / Render', }; // Software / tools they use export const ARTIST_TOOL_LABELS: Record = { illustrator: 'Adobe Illustrator', photoshop: 'Adobe Photoshop', procreate: 'Procreate', affinity: 'Affinity', figma: 'Figma', blender: 'Blender', aftereffects: 'After Effects', clipstudio: 'Clip Studio', }; // ----- Graffiti / Mural artist ----- // Styles export const GRAFFITI_STYLE_LABELS: Record = { throwups: 'Throw-ups', pieces: 'Pieces / Burners', murals: 'Murals', characters: 'Characters', lettering: 'Lettering / Wildstyle', stencil: 'Stencil', handstyles: 'Handstyles / Tags', '3dstyle': '3D Style', abstract: 'Abstract', }; export const GRAFFITI_STYLE_DESCRIPTIONS: Record = { throwups: 'Quick two-color fills', pieces: 'Full-color pieces and burners', murals: 'Large-scale murals and productions', characters: 'Character and figure work', lettering: 'Wildstyle and letter structure', stencil: 'Stencil and street art', handstyles: 'Tags and handstyles', '3dstyle': '3D and anamorphic lettering', abstract: 'Abstract and freestyle', }; // Medium / surface export const GRAFFITI_MEDIUM_LABELS: Record = { spray: 'Spray Cans', rollers: 'Rollers / Emulsion', markers: 'Markers / Inks', brush: 'Brush / Paint', digital: 'Digital / Procreate', }; // Work context export const GRAFFITI_CONTEXT_LABELS: Record = { legalwalls: 'Legal Walls', commissions: 'Commissions', liveart: 'Live Painting', skateparks: 'Skatepark Murals', interiors: 'Interiors / Shops', workshops: 'Workshops', }; // ----- Tattoo artist ----- // Styles export const TATTOO_STYLE_LABELS: Record = { traditional: 'Traditional / Old School', neotraditional: 'Neo-Traditional', fineline: 'Fine Line', blackwork: 'Blackwork', realism: 'Realism', lettering: 'Lettering / Script', illustrative: 'Illustrative', skateflash: 'Skate Flash', ignorant: 'Ignorant / Handpoke Style', japanese: 'Japanese / Irezumi', }; export const TATTOO_STYLE_DESCRIPTIONS: Record = { traditional: 'Bold lines, classic Americana', neotraditional: 'Traditional with modern detail', fineline: 'Delicate single-needle work', blackwork: 'Solid black and pattern', realism: 'Photo-realistic and portraits', lettering: 'Script, lettering and quotes', illustrative: 'Illustrative and graphic', skateflash: 'Skate-culture flash designs', ignorant: 'Ignorant / naive handpoke style', japanese: 'Japanese traditional / irezumi', }; // Method export const TATTOO_METHOD_LABELS: Record = { machine: 'Machine', handpoke: 'Hand-Poke / Stick & Poke', flash: 'Flash Available', custom: 'Custom Designs', coverup: 'Cover-ups', }; // ----- Physical Trainer / Physiotherapist ----- // Focus areas export const TRAINER_FOCUS_LABELS: Record = { strength: 'Strength & Conditioning', mobility: 'Mobility & Flexibility', injuryprevention: 'Injury Prevention', rehab: 'Rehab / Physio', recovery: 'Recovery & Bodywork', balance: 'Balance & Stability', cardio: 'Cardio / Endurance', nutrition: 'Nutrition Guidance', }; export const TRAINER_FOCUS_DESCRIPTIONS: Record = { strength: 'Building strength and power for skating', mobility: 'Range of motion and flexibility', injuryprevention: 'Prehab and reducing injury risk', rehab: 'Post-injury physiotherapy and rehab', recovery: 'Massage, bodywork and recovery', balance: 'Proprioception, balance and stability', cardio: 'Conditioning and endurance', nutrition: 'Fuelling and nutrition advice', }; // Common skate injuries they work with export const TRAINER_INJURY_LABELS: Record = { ankles: 'Ankles', knees: 'Knees', wrists: 'Wrists', hips: 'Hips', back: 'Back / Spine', shoulders: 'Shoulders', heelbruise: 'Heel Bruises', concussion: 'Concussion Protocol', }; // Credentials / setting export const TRAINER_CREDENTIAL_LABELS: Record = { physio: 'Licensed Physiotherapist', osteo: 'Osteopath', certtrainer: 'Certified Trainer', sportstherapist: 'Sports Therapist', massage: 'Massage Therapist', inperson: 'In-Person', remote: 'Remote / Online', }; // ----- Musician / Band (skaters who make music) ----- // Genres export const MUSICIAN_GENRE_LABELS: Record = { punk: 'Punk', hardcore: 'Hardcore', metal: 'Metal', rock: 'Rock', indie: 'Indie / Alt', hiphop: 'Hip-Hop / Rap', electronic: 'Electronic', lofi: 'Lo-Fi / Beats', reggae: 'Reggae / Dub', ska: 'Ska', jazz: 'Jazz', funk: 'Funk / Soul', flamenco: 'Flamenco', folk: 'Folk / Acoustic', experimental: 'Experimental', grunge: 'Grunge', }; // Instruments / roles export const MUSICIAN_INSTRUMENT_LABELS: Record = { vocals: 'Vocals', guitar: 'Guitar', bass: 'Bass', drums: 'Drums', keys: 'Keys / Synth', dj: 'DJ / Turntables', producer: 'Producer / Beats', sax: 'Sax / Brass', strings: 'Strings', other: 'Other', }; // Act type export const MUSICIAN_ACT_LABELS: Record = { solo: 'Solo Artist', band: 'Band', dj: 'DJ', producer: 'Producer', collective: 'Collective', }; // ----- Judges (skate event officials) ----- // Event formats a judge is experienced with. Covers everything from a casket // of flat-ground S.K.A.T. to the biggest touring contest circuits. export const JUDGE_FORMAT_LABELS: Record = { game_of_skate: 'Game of S.K.A.T.E.', best_trick: 'Best Trick', jam: 'Jam Format', contest: 'Contest / Comp', street_league: 'Street League Style', bowl: 'Bowl / Vert', mini_ramp: 'Mini Ramp', park: 'Park', street: 'Street', downhill: 'Downhill / Longboard', freestyle: 'Freestyle', world_tour: 'World Destruction Tour', am_series: 'Am Series / Qualifiers', local_jam: 'Local Jam / Grassroots', online: 'Online / IG Contest', }; // What the judge specializes in evaluating. export const JUDGE_SPECIALTY_LABELS: Record = { head_judge: 'Head Judge', scoring: 'Scoring / Tabulation', mc: 'MC / Announcer', format_design: 'Format Design', rules: 'Rules / Officiating', brackets: 'Brackets / Heats', livestream: 'Livestream Judging', am_scout: 'Am Scouting', }; // Level / circuit the judge has worked. export const JUDGE_LEVEL_LABELS: Record = { local: 'Local', regional: 'Regional', national: 'National', international: 'International', pro: 'Pro Circuit', }; // ----- Crews (community identity, map-anchored) ----- /** * Feature flag: the Crew Treasury (NIP-75 zap goal) is hidden for now. * The code path is kept intact — flip this to `true` to re-enable the * treasury card on the crew detail page and the treasury section in the * crew form. */ export const SHOW_CREW_TREASURY = false; // Crew vibe / focus tags export const CREW_VIBE_LABELS: Record = { street: 'Street', transition: 'Transition / Vert', park: 'Park', diy: 'DIY', cruising: 'Cruising / Filming', oldschool: 'Old School', allterrain: 'All Terrain', girls: 'Girl / Woman', youth: 'Youth / Groms', adaptive: 'Adaptive', punk: 'Punk', hiphop: 'Rap / Hip-Hop', }; /** * A crew's "Lab scene" — an optional craft focus that surfaces the crew inside * the matching category of The Lab. A crew without a focus (`mixed`) is a * regular skate crew and only appears on the Crews page. * * These three scenes map onto the five Lab service types: * builder → building * photo → filming + photography + editing * coaching → coaching * * Stored on the crew event as a single-letter, relay-queryable `t` tag using * the `CREW_FOCUS_TAG` map (e.g. `["t", "crew-focus-builder"]`). */ export type CrewFocus = | 'mixed' | 'builder' | 'photo' | 'coaching' | 'art' | 'music' | 'wellness'; export const CREW_FOCUS_LABELS: Record = { mixed: 'Mixed / General', builder: 'DIY & Builders', photo: 'Photo & Film', coaching: 'Coaching', art: 'Art & Design', music: 'Band & Music', wellness: 'Training & Physio', }; export const CREW_FOCUS_DESCRIPTIONS: Record = { mixed: 'A regular skate crew — shows on the Crews page only.', builder: 'A DIY / park-building crew — also shows in The Lab under Builders.', photo: 'A filming, photography or editing crew — also shows in The Lab under Photo & Film.', coaching: 'A coaching / lessons crew — also shows in The Lab under Coaching.', art: 'An art, design, graffiti or tattoo collective — also shows in The Lab under Art & Design.', music: 'A band or music collective — also shows in The Lab under Music.', wellness: 'A training, fitness or physio crew — also shows in The Lab under Training.', }; /** `t`-tag value stored on the crew event for each non-mixed focus. */ export const CREW_FOCUS_TAG: Record, string> = { builder: 'crew-focus-builder', photo: 'crew-focus-photo', coaching: 'crew-focus-coaching', art: 'crew-focus-art', music: 'crew-focus-music', wellness: 'crew-focus-wellness', }; /** Reverse lookup: `t`-tag value → CrewFocus. */ export const CREW_FOCUS_FROM_TAG: Record = { 'crew-focus-builder': 'builder', 'crew-focus-photo': 'photo', 'crew-focus-coaching': 'coaching', 'crew-focus-art': 'art', 'crew-focus-music': 'music', 'crew-focus-wellness': 'wellness', }; /** * Lab crafts a crew can put out an open call for ("looking for"). * Stored as `["looking-for", ]` tags on the crew event. Slugs map to the * Lab scenes so matching providers can be surfaced. */ export const CREW_LOOKING_FOR_OPTIONS = [ { slug: 'filmer', label: 'Filmer', emoji: '🎥' }, { slug: 'photographer', label: 'Photographer', emoji: '📸' }, { slug: 'editor', label: 'Video Editor', emoji: '✂️' }, { slug: 'coach', label: 'Coach', emoji: '🎓' }, { slug: 'builder', label: 'Ramp Builder', emoji: '🔨' }, { slug: 'artist', label: 'Artist / Designer', emoji: '🎨' }, { slug: 'musician', label: 'Musician / Band', emoji: '🎵' }, { slug: 'members', label: 'New Members', emoji: '🛹' }, ] as const; export type CrewLookingForSlug = (typeof CREW_LOOKING_FOR_OPTIONS)[number]['slug']; export const CREW_LOOKING_FOR_LABELS: Record = Object.fromEntries(CREW_LOOKING_FOR_OPTIONS.map((o) => [o.slug, { label: o.label, emoji: o.emoji }])); /** NIP-75 Zap Goal kind — used for crew funding goals / treasury. */ export const ZAP_GOAL_KIND = 9041; /** Lab service types that map onto a crew scene (used by LabCrewsStrip). */ export type LabServiceType = | 'coaching' | 'filming' | 'editing' | 'photography' | 'building' | 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician'; /** Map a Lab service type to the crew focus that belongs in that scene. */ export function crewFocusForServiceType( serviceType: LabServiceType ): Exclude { switch (serviceType) { case 'building': return 'builder'; case 'coaching': return 'coaching'; case 'artist': case 'graffiti': case 'tattoo': return 'art'; case 'musician': return 'music'; case 'trainer': return 'wellness'; default: return 'photo'; // filming, editing, photography } } export interface SkateCrew { id: string; pubkey: string; identifier: string; name: string; bio: string; // Freeform crew manifesto / about geohash?: string; // Home turf (map anchor) location?: string; image?: string; // Crew logo / photo vibes: string[]; // Style/focus tags focus: CrewFocus; // Optional Lab scene (builder/photo/coaching) or 'mixed' agentEnabled: boolean; // Whether the crew's AI agent ("🐝 Sasquatch") is on founded?: string; // Year founded (cosmetic) website?: string; instagram?: string; bandName?: string; // Crew's skate band / music project name (`band` tag) bandUrl?: string; // Link to the band's music (Spotify/Bandcamp/YT/Nostr) (`band-url` tag) social?: string; // First social link (backward compat) socials: string[]; // All additional social links (`social` tags) nip05?: string; // Crew's NIP-05 identifier (verified against founder pubkey) noffer?: string; // Optional shared tip jar (CLINK) members: string[]; // Member pubkeys (hex) via `p` tags admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker) spotRefs: string[]; // Home spots (kind 30818 refs) labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d) goalRef?: string; // NIP-75 zap goal event id (crew funding goal / treasury) lookingFor: string[]; // Lab craft slugs the crew is hiring for (e.g. "filmer", "editor") sponsorRef?: string; // Sponsoring skate shop coordinate (30017:pubkey:d) via `sponsor` tag coordinator?: CrewCoordinator; // Cordn/MLS encrypted-chat coordinator for this crew videoUrl?: string; // Crew's headline video / montage — YouTube or Instagram URL (`video` tag) crewRoles: CrewMemberRole[]; // Optional skate roles (filmer/photographer/…) assigned to members createdAt: number; event: NostrEvent; } /** * Skate roles a crew can assign to a member — the *functions* on a real team * (the filmer is as vital as the skaters). These are additive to membership and * purely a flex/label: they grant no powers (only `admin` does that). A member * can hold a role AND be an admin. * * Stored as a dedicated tag on the crew event: * `["crew-role", , ]` */ export type CrewRole = | 'filmer' | 'photographer' | 'editor' | 'artist' | 'manager' | 'og'; export interface CrewMemberRole { pubkey: string; role: CrewRole; } /** Human labels + emoji for each crew role (skate-native language). */ export const CREW_ROLE_META: Record = { filmer: { label: 'Filmer', emoji: '🎥' }, photographer: { label: 'Photographer', emoji: '📷' }, editor: { label: 'Editor', emoji: '✂️' }, artist: { label: 'Artist', emoji: '🎨' }, manager: { label: 'Team Manager', emoji: '📋' }, og: { label: 'OG', emoji: '👑' }, }; /** All assignable crew roles, in display order. */ export const CREW_ROLE_ORDER: CrewRole[] = ['filmer', 'photographer', 'editor', 'artist', 'manager', 'og']; const CREW_ROLE_SET = new Set(CREW_ROLE_ORDER); /** * Parse `crew-role` tags into member→role assignments. Only roles whose pubkey * is a valid hex key and whose role is recognised are kept. A member may appear * multiple times (e.g. filmer + editor). */ export function parseCrewRoles(event: NostrEvent): CrewMemberRole[] { const out: CrewMemberRole[] = []; const seen = new Set(); for (const [name, pubkey, role] of event.tags) { if (name !== 'crew-role') continue; if (!/^[0-9a-f]{64}$/i.test(pubkey ?? '')) continue; if (!role || !CREW_ROLE_SET.has(role)) continue; const key = `${pubkey.toLowerCase()}:${role}`; if (seen.has(key)) continue; seen.add(key); out.push({ pubkey: pubkey.toLowerCase(), role: role as CrewRole }); } return out; } /** * A crew's encrypted-group coordinator (Cordn / MLS Delivery Service). * * SkateSpots crews keep their PUBLIC identity on Nostr (roster, spots, events), * but the crew's private group chat is designed to run over Cordn — an MLS * (RFC 9420) delivery-service coordinator, one per group, reached over ContextVM. * A crew can advertise which coordinator it uses so members' clients know where * to publish KeyPackages and fetch group messages. * * This is intentionally just a pointer: the crew event stays public and * discoverable, while message content, membership secrets and ratchets live * client-side under MLS. See the "Marmot / Concord / Cordn" analysis — Cordn is * the chosen fit because its coordinator-per-group model mirrors a crew's * leader/coordinator structure, it is browser/TypeScript-native, and it leaks no * protocol-specific events onto public relays. */ export interface CrewCoordinator { /** ContextVM server / coordinator endpoint (https or wss URL). */ url: string; /** Optional coordinator service pubkey (hex) for ContextVM addressing. */ pubkey?: string; /** Human pubkey (hex) responsible for running/operating the coordinator, if delegated. */ operator?: string; } export interface SkateBuilder { id: string; pubkey: string; identifier: string; featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid title: string; bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // Combined build types + materials + scale tags experience?: string; // Years building rate?: string; // Freeform rate / quote basis phone?: string; email?: string; website?: string; portfolio?: string; // Link to past builds (IG, site) social?: string; gear?: string; // Freeform "what we bring / how we work" serviceArea?: string; // How far they travel (e.g. "Catalonia + willing to travel EU") insured?: boolean; // Licensed / insured (relevant for public park companies) noffer?: string; entityType: EntityType; members: string[]; spotRefs?: string[]; // Spots they've built (kind 30818 refs) crewRef?: string; // Linked crew coordinate (35006:pubkey:d) createdAt: number; event: NostrEvent; } export interface SkateCoach { id: string; pubkey: string; identifier: string; featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid 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" crewRef?: string; // Linked crew coordinate (35006:pubkey:d) createdAt: number; event: NostrEvent; } export interface SkateArtist { id: string; pubkey: string; identifier: string; featuredUntil?: number; title: string; bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // services + medium + tools tags experience?: string; rate?: string; phone?: string; email?: string; website?: string; portfolio?: string; // portfolio / shop link social?: string; noffer?: string; entityType: EntityType; members: string[]; crewRef?: string; createdAt: number; event: NostrEvent; } export interface SkateGraffiti { id: string; pubkey: string; identifier: string; featuredUntil?: number; title: string; bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // style + medium + context tags experience?: string; rate?: string; phone?: string; email?: string; website?: string; portfolio?: string; social?: string; crew?: string; // freeform graffiti crew name (distinct from skate crew) noffer?: string; entityType: EntityType; members: string[]; spotRefs?: string[]; // walls / spots painted (kind 30818 refs) crewRef?: string; createdAt: number; event: NostrEvent; } export interface SkateTattoo { id: string; pubkey: string; identifier: string; featuredUntil?: number; title: string; bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // style + method tags experience?: string; rate?: string; phone?: string; email?: string; website?: string; portfolio?: string; social?: string; studio?: string; // studio / shop name booksOpen?: boolean; // currently taking bookings noffer?: string; entityType: EntityType; members: string[]; crewRef?: string; createdAt: number; event: NostrEvent; } export interface SkateTrainer { id: string; pubkey: string; identifier: string; featuredUntil?: number; title: string; bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // focus + injury + credential tags experience?: string; rate?: string; phone?: string; email?: string; website?: string; portfolio?: string; // profile / booking link social?: string; credentials?: string; // freeform certifications remote?: boolean; // offers remote/online coaching noffer?: string; entityType: EntityType; members: string[]; crewRef?: string; createdAt: number; event: NostrEvent; } export interface SkateMusician { id: string; pubkey: string; identifier: string; featuredUntil?: number; title: string; // artist / band name bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // genre + instrument tags actType?: string; // solo / band / dj / producer / collective experience?: string; rate?: string; // e.g. booking rate for gigs phone?: string; email?: string; website?: string; spotify?: string; // Spotify link bandcamp?: string; // Bandcamp link soundcloud?: string; // SoundCloud link youtube?: string; // YouTube link social?: string; noffer?: string; entityType: EntityType; members: string[]; // band members (collective) crewRef?: string; // linked skate crew coordinate createdAt: number; event: NostrEvent; } export interface SkateFilmer { id: string; pubkey: string; identifier: string; featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid 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" crewRef?: string; // Linked crew coordinate (35006:pubkey:d) createdAt: number; event: NostrEvent; } export interface SkateEditor { id: string; pubkey: string; identifier: string; featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid 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) crewRef?: string; // Linked crew coordinate (35006:pubkey:d) createdAt: number; event: NostrEvent; } export interface SkatePhotographer { id: string; pubkey: string; identifier: string; featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid 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" crewRef?: string; // Linked crew coordinate (35006:pubkey:d) 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[]; featuredUntil?: number; // Featured tour listing expiry (unix seconds), if paid 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, crewRef: parseCrewRef(event), featuredUntil: parseFeaturedUntil(event), 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, crewRef: parseCrewRef(event), featuredUntil: parseFeaturedUntil(event), 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, crewRef: parseCrewRef(event), featuredUntil: parseFeaturedUntil(event), 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, crewRef: parseCrewRef(event), featuredUntil: parseFeaturedUntil(event), createdAt: event.created_at, event, }; } export function parseSkateBuilder(event: NostrEvent): SkateBuilder | null { if (event.kind !== SKATE_BUILDER_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 !== 'skatebuilder').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 serviceArea = event.tags.find(([name]) => name === 'service_area')?.[1]; const insured = event.tags.some(([name, value]) => name === 'insured' && value === 'true'); 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, serviceArea, insured, noffer, entityType, members, spotRefs: spotRefs.length > 0 ? spotRefs : undefined, crewRef: parseCrewRef(event), featuredUntil: parseFeaturedUntil(event), createdAt: event.created_at, event, }; } /** * Shared parser for the "simple craft" Lab profiles that all follow the common * base schema (title + bio + t-tag skills + contact/links), distinguished only * by kind and self-tag. Returns the common fields; callers layer craft-specific * extras on top. */ function parseCraftBase(event: NostrEvent, selfTag: string) { 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 nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; 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: clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN), geohash: event.tags.find(([name]) => name === 'g')?.[1], image: event.tags.find(([name]) => name === 'image')?.[1], skills: event.tags.filter(([name, value]) => name === 't' && value !== selfTag).map(([, value]) => value), experience: event.tags.find(([name]) => name === 'experience')?.[1], rate: event.tags.find(([name]) => name === 'rate')?.[1], phone: event.tags.find(([name]) => name === 'phone')?.[1], email: event.tags.find(([name]) => name === 'email')?.[1], website: event.tags.find(([name]) => name === 'website')?.[1], portfolio: event.tags.find(([name]) => name === 'portfolio')?.[1], social: event.tags.find(([name]) => name === 'social')?.[1], noffer: isValidNoffer(nofferTag) ? nofferTag : undefined, entityType: parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]), members: parseMemberPubkeys(event), crewRef: parseCrewRef(event), featuredUntil: parseFeaturedUntil(event), createdAt: event.created_at, event, }; } export function parseSkateArtist(event: NostrEvent): SkateArtist | null { if (event.kind !== SKATE_ARTIST_KIND) return null; const base = parseCraftBase(event, 'skateartist'); if (!base) return null; return base; } export function parseSkateGraffiti(event: NostrEvent): SkateGraffiti | null { if (event.kind !== SKATE_GRAFFITI_KIND) return null; const base = parseCraftBase(event, 'skategraffiti'); if (!base) return null; const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) .map(([, value]) => value); return { ...base, crew: clampStr(event.tags.find(([name]) => name === 'crew')?.[1], 100), spotRefs: spotRefs.length > 0 ? spotRefs : undefined, }; } export function parseSkateTattoo(event: NostrEvent): SkateTattoo | null { if (event.kind !== SKATE_TATTOO_KIND) return null; const base = parseCraftBase(event, 'skatetattoo'); if (!base) return null; return { ...base, studio: clampStr(event.tags.find(([name]) => name === 'studio')?.[1], MAX_LOCATION_LEN), booksOpen: event.tags.some(([name, value]) => name === 'books_open' && value === 'true'), }; } export function parseSkateTrainer(event: NostrEvent): SkateTrainer | null { if (event.kind !== SKATE_TRAINER_KIND) return null; const base = parseCraftBase(event, 'skatetrainer'); if (!base) return null; return { ...base, credentials: clampStr(event.tags.find(([name]) => name === 'credentials')?.[1], MAX_LOCATION_LEN), remote: event.tags.some(([name, value]) => name === 'remote' && value === 'true'), }; } export function parseSkateMusician(event: NostrEvent): SkateMusician | null { if (event.kind !== SKATE_MUSICIAN_KIND) return null; const base = parseCraftBase(event, 'skatemusician'); if (!base) return null; const httpOnly = (v?: string) => (v && /^https?:\/\//i.test(v.trim()) ? v.trim() : undefined); return { ...base, actType: event.tags.find(([name]) => name === 'act')?.[1], spotify: httpOnly(event.tags.find(([name]) => name === 'spotify')?.[1]), bandcamp: httpOnly(event.tags.find(([name]) => name === 'bandcamp')?.[1]), soundcloud: httpOnly(event.tags.find(([name]) => name === 'soundcloud')?.[1]), youtube: httpOnly(event.tags.find(([name]) => name === 'youtube')?.[1]), }; } export function parseSkateCrew(event: NostrEvent): SkateCrew | null { if (event.kind !== SKATE_CREW_KIND) return null; const hasCrewTag = event.tags.some(([name, value]) => name === 't' && value === 'skatecrew'); if (!hasCrewTag) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const name = event.tags.find(([name]) => name === 'title')?.[1] || event.tags.find(([name]) => name === 'name')?.[1]; if (!d || !name) return null; if (name.trim().length < MIN_TITLE_LEN || name.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]; // Vibes are `t` tags that aren't the crew marker, a crew-focus scene tag, or // the crew-agent marker. const vibes = event.tags .filter(([name, value]) => name === 't' && value !== 'skatecrew' && value !== 'crew-agent' && !CREW_FOCUS_FROM_TAG[value] ) .map(([, value]) => value); // Lab scene focus — first matching crew-focus `t` tag wins, else 'mixed'. const focusTag = event.tags.find(([name, value]) => name === 't' && !!CREW_FOCUS_FROM_TAG[value])?.[1]; const focus: CrewFocus = focusTag ? CREW_FOCUS_FROM_TAG[focusTag] : 'mixed'; // Crew AI agent enabled when the `crew-agent` marker tag is present. const agentEnabled = event.tags.some(([name, value]) => name === 't' && value === 'crew-agent'); const founded = event.tags.find(([name]) => name === 'founded')?.[1]; const website = event.tags.find(([name]) => name === 'website')?.[1]; const instagram = event.tags.find(([name]) => name === 'instagram')?.[1]; // Optional crew band / music project — many crews double as local bands. const bandName = clampStr(event.tags.find(([name]) => name === 'band')?.[1], 100); const bandUrlRaw = event.tags.find(([name]) => name === 'band-url')?.[1]?.trim(); const bandUrl = bandUrlRaw && /^https?:\/\//i.test(bandUrlRaw) ? bandUrlRaw : undefined; const socials = event.tags .filter(([name, value]) => name === 'social' && !!value?.trim()) .map(([, value]) => value.trim()); const social = socials[0]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; const nip05 = clampStr(event.tags.find(([name]) => name === 'nip05')?.[1], 100); const members = parseMemberPubkeys(event); // Admins must be a subset of claimed members. const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk)); const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) .map(([, value]) => value); const labRefs = parseLabRefs(event); // NIP-75 zap goal linked to this crew (crew treasury / funding goal). const goalRef = event.tags.find(([name, value]) => name === 'goal' && /^[0-9a-f]{64}$/i.test(value ?? ''))?.[1]; // "Looking for" — Lab craft slugs the crew is currently hiring for. const lookingFor = event.tags .filter(([name, value]) => name === 'looking-for' && !!value?.trim()) .map(([, value]) => value.trim().toLowerCase()); // Sponsoring shop — a NIP-15 stall coordinate (30017:pubkey:d). const sponsorRef = event.tags.find( ([name, value]) => name === 'sponsor' && value?.startsWith(`${SKATE_SHOP_KIND}:`), )?.[1]; // Cordn / MLS coordinator for the crew's encrypted group chat. // Tag shape: ['coordinator', , , ] const coordinatorTag = event.tags.find(([name, value]) => name === 'coordinator' && !!value?.trim()); const coordinator: CrewCoordinator | undefined = coordinatorTag ? { url: coordinatorTag[1].trim(), pubkey: /^[0-9a-f]{64}$/i.test(coordinatorTag[2] ?? '') ? coordinatorTag[2] : undefined, operator: /^[0-9a-f]{64}$/i.test(coordinatorTag[3] ?? '') ? coordinatorTag[3] : undefined, } : undefined; // Crew video / montage — only kept if it parses as a YouTube/Instagram URL. const videoRaw = event.tags.find(([name]) => name === 'video')?.[1]?.trim(); const videoUrl = videoRaw && parseCrewVideo(videoRaw) ? videoRaw : undefined; // Skate roles (filmer/photographer/…) — only for people actually on the roster. const memberSet = new Set([event.pubkey.toLowerCase(), ...members]); const crewRoles = parseCrewRoles(event).filter((r) => memberSet.has(r.pubkey)); return { id: event.id, pubkey: event.pubkey, identifier: d, name: name.slice(0, MAX_TITLE_LEN), bio: event.content.slice(0, MAX_DESCRIPTION_LEN), location, geohash, image, vibes, focus, agentEnabled, founded, website, instagram, bandName, bandUrl, social, socials, nip05, noffer, members, admins, spotRefs, labRefs, goalRef, lookingFor, sponsorRef, coordinator, videoUrl, crewRoles, 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, featuredUntil: parseFeaturedUntil(event), createdAt: event.created_at, event, }; } // Tour skill levels export const TOUR_SKILL_LEVELS = { beginner: { label: 'Beginner', emoji: '🌱', description: 'New to skating or basic tricks' }, intermediate: { label: 'Intermediate', emoji: '🔥', description: 'Comfortable with most terrain' }, advanced: { label: 'Advanced', emoji: '💀', description: 'Experienced skater, gnarly spots' }, all: { label: 'All Levels', emoji: '🛹', description: 'Suitable for everyone' }, } as const; // Tour spot types (what kind of spots the tour covers) export const TOUR_SPOT_TYPES = { street: { label: 'Street', emoji: '🛣️' }, park: { label: 'Park', emoji: '🏟️' }, diy: { label: 'DIY', emoji: '🔨' }, transition: { label: 'Transition', emoji: '🌊' }, hidden: { label: 'Hidden Gems', emoji: '💎' }, } as const; // Tour categories export const TOUR_CATEGORIES = { local: { label: 'Local Tour', emoji: '🏠', color: '#06b6d4', // Cyan description: 'Show hidden spots in your city', durationLabel: '2-8 hours', }, national: { label: 'National Tour', emoji: '🚐', color: '#f97316', // Orange description: 'Multi-city road trips across the country', durationLabel: '2-14 days', }, international: { label: 'International Tour', emoji: '✈️', color: '#a855f7', // Purple description: 'Cross-border adventures across multiple countries', durationLabel: '1-8 weeks', }, destruction: { label: 'World Destruction', emoji: '💀', color: '#ef4444', // Red description: 'KOTR-style global team competition', durationLabel: '1-12 months', }, } as const; export type TourCategory = keyof typeof TOUR_CATEGORIES; // Destruction Tour Challenge interface export interface DestructionChallenge { id: string; title: string; description?: string; points: number; isSecret?: boolean; // Hidden until competition starts (all challenges secret by default) revealDate?: number; // Unix timestamp when challenge becomes visible (defaults to tour start date) } // Destruction Tour Team interface export interface DestructionTeam { id: string; name: string; members: string[]; // pubkeys of team members captainPubkey: string; points: number; completedChallenges: string[]; // Challenge IDs } // Destruction Tour Submission (clip/proof) export interface DestructionSubmission { id: string; teamId: string; challengeId: string; submitterPubkey: string; videoUrl?: string; // External link (YouTube, Instagram, etc.) blossomUrl?: string; // Blossom-hosted video description?: string; submittedAt: number; status: 'pending' | 'approved' | 'rejected'; pointsAwarded?: number; } // Gear Categories export const GEAR_CATEGORIES = { deck: 'Deck', trucks: 'Trucks', wheels: 'Wheels', bearings: 'Bearings', shoes: 'Skate Shoes', clothing: 'Clothing', protective: 'Protective Gear', stickers: 'Stickers', wax: 'Wax', tools: 'Tools', griptape: 'Griptape', rubbrick: 'Rub Brick', } as const; // Gear Conditions export const GEAR_CONDITIONS = { new: { label: 'New', emoji: '🆕', description: 'Never used, still in packaging' }, like_new: { label: 'Like New', emoji: '👍', description: 'Barely used, excellent condition' }, good: { label: 'Good', emoji: '✅', description: 'Normal wear, functions perfectly' }, well_used: { label: 'Well Used', emoji: '⚠️', description: 'Shows wear but still works' }, } as const; // Price Types export const PRICE_TYPES = { fixed: { label: 'Fixed Price', description: 'Set price in USD or sats' }, free: { label: 'Free', description: 'Giving away' }, trade: { label: 'Trade', description: 'Looking to swap' }, offer: { label: 'Make Offer', description: 'Price negotiable' }, } as const; // Listing Types export const LISTING_TYPES = { selling: { label: 'Selling', emoji: '💰', description: 'Listing gear for sale' }, wtb: { label: 'Want to Buy', emoji: '🔍', description: 'Looking for specific gear' }, } as const; // Size Options by Category export const SIZE_OPTIONS: Record = { deck: ['7.5"', '7.75"', '8.0"', '8.125"', '8.25"', '8.38"', '8.5"', '8.75"', '9.0"+'], trucks: ['129', '139', '144', '149', '159'], wheels: ['50mm', '52mm', '54mm', '56mm', '58mm', '60mm'], shoes: ['US 6', 'US 6.5', 'US 7', 'US 7.5', 'US 8', 'US 8.5', 'US 9', 'US 9.5', 'US 10', 'US 10.5', 'US 11', 'US 11.5', 'US 12', 'US 13', 'US 14'], clothing: ['XS', 'S', 'M', 'L', 'XL', 'XXL'], }; // Truck Heights export const TRUCK_HEIGHTS = ['Low', 'Mid', 'High', 'Inverted']; // Wheel Hardness export const WHEEL_HARDNESS = ['78a', '87a', '92a', '97a', '99a', '101a']; // Category-specific required fields for validation export const CATEGORY_REQUIRED_FIELDS: Record = { deck: { size: true, condition: true }, trucks: { size: true, condition: true }, wheels: { size: true, condition: true }, bearings: { condition: true }, shoes: { size: true, condition: true }, clothing: { size: true, condition: true }, protective: { size: false, condition: true }, stickers: { size: false, condition: false }, wax: { size: false, condition: false }, tools: { size: false, condition: true }, griptape: { size: false, condition: true }, rubbrick: { size: false, condition: true }, }; // Report reasons for NIP-56 export const LISTING_REPORT_REASONS = { category_mismatch: { label: 'Wrong Category', description: 'Item is listed in the wrong category' }, misleading_description: { label: 'Misleading Description', description: 'Description does not match the actual item' }, fake_photos: { label: 'Fake/Stolen Photos', description: 'Photos are stock images or stolen from elsewhere' }, scam: { label: 'Suspected Scam', description: 'This listing appears to be a scam' }, spam: { label: 'Spam/Duplicate', description: 'Spam or duplicate listing' }, inappropriate: { label: 'Inappropriate Content', description: 'Contains inappropriate or offensive content' }, counterfeit: { label: 'Counterfeit Goods', description: 'Item appears to be fake or counterfeit' }, } as const; // Expiration Options (in days) export const EXPIRATION_OPTIONS = [ { value: 7, label: '7 days' }, { value: 14, label: '14 days' }, { value: 30, label: '30 days' }, ]; // Currency Options (ISO 4217 codes + Bitcoin) export const CURRENCY_OPTIONS = [ // Crypto { code: 'sats', label: 'sats', symbol: '₿', region: 'Bitcoin' }, { code: 'BTC', label: 'BTC', symbol: '₿', region: 'Bitcoin' }, // Major Fiat - Americas { code: 'USD', label: 'USD', symbol: '$', region: 'United States' }, { code: 'CAD', label: 'CAD', symbol: 'C$', region: 'Canada' }, { code: 'MXN', label: 'MXN', symbol: '$', region: 'Mexico' }, { code: 'BRL', label: 'BRL', symbol: 'R$', region: 'Brazil' }, { code: 'ARS', label: 'ARS', symbol: '$', region: 'Argentina' }, { code: 'CLP', label: 'CLP', symbol: '$', region: 'Chile' }, { code: 'COP', label: 'COP', symbol: '$', region: 'Colombia' }, { code: 'PEN', label: 'PEN', symbol: 'S/', region: 'Peru' }, // Major Fiat - Europe { code: 'EUR', label: 'EUR', symbol: '€', region: 'Eurozone' }, { code: 'GBP', label: 'GBP', symbol: '£', region: 'United Kingdom' }, { code: 'CHF', label: 'CHF', symbol: 'Fr', region: 'Switzerland' }, { code: 'SEK', label: 'SEK', symbol: 'kr', region: 'Sweden' }, { code: 'NOK', label: 'NOK', symbol: 'kr', region: 'Norway' }, { code: 'DKK', label: 'DKK', symbol: 'kr', region: 'Denmark' }, { code: 'PLN', label: 'PLN', symbol: 'zł', region: 'Poland' }, { code: 'CZK', label: 'CZK', symbol: 'Kč', region: 'Czech Republic' }, { code: 'HUF', label: 'HUF', symbol: 'Ft', region: 'Hungary' }, { code: 'RON', label: 'RON', symbol: 'lei', region: 'Romania' }, // Major Fiat - Asia Pacific { code: 'JPY', label: 'JPY', symbol: '¥', region: 'Japan' }, { code: 'CNY', label: 'CNY', symbol: '¥', region: 'China' }, { code: 'KRW', label: 'KRW', symbol: '₩', region: 'South Korea' }, { code: 'AUD', label: 'AUD', symbol: 'A$', region: 'Australia' }, { code: 'NZD', label: 'NZD', symbol: 'NZ$', region: 'New Zealand' }, { code: 'SGD', label: 'SGD', symbol: 'S$', region: 'Singapore' }, { code: 'HKD', label: 'HKD', symbol: 'HK$', region: 'Hong Kong' }, { code: 'TWD', label: 'TWD', symbol: 'NT$', region: 'Taiwan' }, { code: 'THB', label: 'THB', symbol: '฿', region: 'Thailand' }, { code: 'PHP', label: 'PHP', symbol: '₱', region: 'Philippines' }, { code: 'IDR', label: 'IDR', symbol: 'Rp', region: 'Indonesia' }, { code: 'MYR', label: 'MYR', symbol: 'RM', region: 'Malaysia' }, { code: 'INR', label: 'INR', symbol: '₹', region: 'India' }, // Middle East & Africa { code: 'AED', label: 'AED', symbol: 'د.إ', region: 'UAE' }, { code: 'SAR', label: 'SAR', symbol: '﷼', region: 'Saudi Arabia' }, { code: 'ILS', label: 'ILS', symbol: '₪', region: 'Israel' }, { code: 'TRY', label: 'TRY', symbol: '₺', region: 'Turkey' }, { code: 'ZAR', label: 'ZAR', symbol: 'R', region: 'South Africa' }, { code: 'NGN', label: 'NGN', symbol: '₦', region: 'Nigeria' }, { code: 'EGP', label: 'EGP', symbol: 'E£', region: 'Egypt' }, ]; // Helper to get currency symbol from code export function getCurrencySymbol(code: string): string { const currency = CURRENCY_OPTIONS.find(c => c.code === code); return currency?.symbol || code; } // Helper to format price with correct currency symbol export function formatPrice(price: { amount: string; currency: string } | undefined, priceType?: string): string { if (!price) { if (priceType === 'free') return 'FREE'; if (priceType === 'trade') return 'Trade'; if (priceType === 'offer') return 'Make Offer'; return 'Offer'; } const symbol = getCurrencySymbol(price.currency); // For Bitcoin/sats, show symbol before amount if (price.currency === 'sats' || price.currency === 'BTC') { return `${symbol}${price.amount}`; } // For most currencies, show symbol before amount return `${symbol}${price.amount} ${price.currency}`; } // NIP-99 Marketplace (Gear Swap) export interface MarketplaceItem { id: string; pubkey: string; identifier: string; title: string; description: string; summary?: string; price?: { amount: string; currency: string; frequency?: string; }; priceType?: 'fixed' | 'free' | 'trade' | 'offer'; condition?: 'new' | 'like_new' | 'good' | 'well_used'; listingType?: 'selling' | 'wtb'; size?: string; expiration?: number; // Unix timestamp images: string[]; location?: string; geohash?: string; category: string[]; status?: string; // 'active' | 'sold' | 'pending' spotReference?: string; // 'a' tag pointing to spot event availability?: string; // When seller is at the spot createdAt: number; publishedAt?: number; event: NostrEvent; } // NIP-51 Spot Collections (Bookmark sets) export interface SpotCollection { id: string; pubkey: string; identifier: string; title: string; description: string; image?: string; spotRefs: string[]; // Array of "a" tag references: "30818:pubkey:d-tag" createdAt: number; event: NostrEvent; } export function parseSpotCollection(event: NostrEvent): SpotCollection | null { if (event.kind !== SPOT_COLLECTION_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; // Must have skatespots tag to be a skate spot collection const hasSkatespotsTag = event.tags.some( ([name, value]) => name === 't' && value === 'skatespots' ); if (!d || !title || !hasSkatespotsTag) return null; const description = event.tags.find(([name]) => name === 'description')?.[1] || event.content; const image = event.tags.find(([name]) => name === 'image')?.[1]; // Get all "a" tags that reference spots (kind 30818) const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith(`${SKATE_SPOT_KIND}:`)) .map(([, value]) => value); return { id: event.id, pubkey: event.pubkey, identifier: d, title, description, image, spotRefs, createdAt: event.created_at, event, }; } export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null { if (event.kind !== MARKETPLACE_ITEM_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; if (!d || !title) return null; const summary = event.tags.find(([name]) => name === 'summary')?.[1]; const location = event.tags.find(([name]) => name === 'location')?.[1]; const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const status = event.tags.find(([name]) => name === 'status')?.[1]; const availability = event.tags.find(([name]) => name === 'available')?.[1]; const spotReference = event.tags.find(([name]) => name === 'a')?.[1]; const publishedAtTag = event.tags.find(([name]) => name === 'published_at')?.[1]; // Parse new fields const priceType = event.tags.find(([name]) => name === 'price_type')?.[1] as MarketplaceItem['priceType']; const condition = event.tags.find(([name]) => name === 'condition')?.[1] as MarketplaceItem['condition']; const listingType = event.tags.find(([name]) => name === 'listing_type')?.[1] as MarketplaceItem['listingType']; const size = event.tags.find(([name]) => name === 'size')?.[1]; const expirationTag = event.tags.find(([name]) => name === 'expiration')?.[1]; const expiration = expirationTag ? parseInt(expirationTag) : undefined; // Parse price tag: ["price", "40", "USD", "optional-frequency"] const priceTag = event.tags.find(([name]) => name === 'price'); const price = priceTag ? { amount: priceTag[1] || '0', currency: priceTag[2] || 'USD', frequency: priceTag[3], } : undefined; // Parse images (NIP-58 format) const images = event.tags .filter(([name]) => name === 'image') .map(([, url]) => url); // Parse categories/tags const category = event.tags .filter(([name]) => name === 't') .map(([, value]) => value); return { id: event.id, pubkey: event.pubkey, identifier: d, title, description: event.content, summary, price, priceType, condition, listingType, size, expiration, images, location, geohash, category, status, spotReference, availability, createdAt: event.created_at, publishedAt: publishedAtTag ? parseInt(publishedAtTag) : undefined, event, }; } export interface SkateJudge { id: string; pubkey: string; identifier: string; featuredUntil?: number; title: string; // judge name / handle bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // format + specialty + level tags experience?: string; // years judging rate?: string; // booking rate for events phone?: string; email?: string; website?: string; portfolio?: string; // results archive / profile link social?: string; eventsJudged?: string; // freeform count / notable events judged noffer?: string; entityType: EntityType; members: string[]; crewRef?: string; createdAt: number; event: NostrEvent; } export function parseSkateJudge(event: NostrEvent): SkateJudge | null { if (event.kind !== SKATE_JUDGE_KIND) return null; const base = parseCraftBase(event, 'skatejudge'); if (!base) return null; return { ...base, eventsJudged: event.tags.find(([name]) => name === 'events_judged')?.[1], }; }