import type { NostrEvent } from '@nostrify/nostrify'; import ngeohash from 'ngeohash'; import { countLeadingZeroBits } from '@/lib/pow'; import { parseCrewVideo } from '@/lib/videoEmbed'; import { isAdminPubkey } from '@/lib/admins'; import { isValidClinkOffer } from '@/lib/clink'; import { SPOT_FEATURE_KEYS } from '@/lib/labelKeys'; // 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://relay.skate-spots.org'; // ─── Validation helpers ─────────────────────────────────────────────────────── /** Max lengths for user-supplied text fields */ const MAX_TITLE_LEN = 100; const MAX_DESCRIPTION_LEN = 3000; const MAX_LOCATION_LEN = 200; // 1, not 2: a profile's title falls back to the user's display name, and a // single-character display name would otherwise make the whole profile fail // validation and silently vanish. const MIN_TITLE_LEN = 1; 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 CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined) export const EVENT_UPDATE_KIND = 7103; // Regular event - host broadcast/update pinned to a calendar event. SkateSpots-native. 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 SKATE_ORGANIZER_KIND = 35013; // Event organizers / hosts (jams, comps, meetups) export const SKATE_BARBER_KIND = 35014; // Barbers / hairstylists (skater-friendly cuts) export const SKATE_DESIGNER_KIND = 35015; // Clothing designers (custom clothes, team kits, apparel) export const SKATE_TRANSPORT_KIND = 35016; // Transport / drivers (people, gear, cargo, air/land/sea) 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. export const CREW_ENDORSEMENT_KIND = 9087; // Regular event — an admin endorses ("vouches for") a join-request; the founder ratifies. SkateSpots-native. export const COLLECTIVE_JOIN_REQUEST_KIND = 36387; // Addressable Lab-collective join-request — someone asks to join a Lab collective (studio/build team). Notifiable. SkateSpots-native. export const NEWSLETTER_KIND = 30023; // NIP-23 long-form article — SkateSpots newsletter issues (admin-published, tagged `skatespotsnews`). export const APP_DATA_KIND = 30078; // NIP-78 application-specific data (addressable). Used for the newsletter subscription marker. /** `d`-tag identifier for the per-user newsletter subscription marker (kind 30078). */ export const NEWSLETTER_SUB_D = 'skatespots:newsletter:subscription'; /** `t`-tag every SkateSpots newsletter issue carries, so it's queryable at the relay level. */ export const NEWSLETTER_TAG = 'skatespotsnews'; /** * A SkateSpots newsletter issue published as a NIP-23 long-form article (kind * 30023) by an app admin. The `content` is Markdown; standard NIP-23 tags carry * the metadata (title, summary, image, published_at). */ export interface SkateNewsletterIssue { id: string; pubkey: string; /** NIP-23 `d` identifier — stable slug for the issue (e.g. "issue-6"). */ identifier: string; title: string; summary?: string; image?: string; /** Markdown body. */ content: string; /** Publication time (NIP-23 `published_at` if present, else created_at). */ publishedAt: number; createdAt: number; event: NostrEvent; } /** * Parse + validate a newsletter issue. Only trusts issues authored by a * SkateSpots admin and tagged with NEWSLETTER_TAG — this is the trust boundary * that stops anyone from spamming the community's newsletter mailbox. */ export function parseNewsletterIssue(event: NostrEvent): SkateNewsletterIssue | null { if (event.kind !== NEWSLETTER_KIND) return null; if (!isAdminPubkey(event.pubkey)) return null; const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; const identifier = tag('d'); const title = tag('title'); if (!identifier || !title) return null; const isNewsletter = event.tags.some(([n, v]) => n === 't' && v === NEWSLETTER_TAG); if (!isNewsletter) return null; const publishedAtRaw = tag('published_at'); const publishedAt = publishedAtRaw ? parseInt(publishedAtRaw, 10) : event.created_at; return { id: event.id, pubkey: event.pubkey, identifier, title, summary: tag('summary'), image: tag('image'), content: event.content ?? '', publishedAt: Number.isFinite(publishedAt) ? publishedAt : event.created_at, createdAt: event.created_at, event, }; } /** * 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'; /** Admin VOUCHES for the target (the positive counterpart to `hidden`). */ export const MODERATION_LABEL_VERIFIED = 'verified'; /** * UNDO: the admin retracted a previous verdict. Nothing is hidden or vouched — * the target goes back to neutral. Newest verdict wins, so this supersedes an * earlier `hidden`/`verified` label. */ export const MODERATION_LABEL_CLEARED = 'cleared'; /** * A moderator APPROVED a private-spot ownership claim. Tagged `["a", coord]` + * `["p", claimantPubkey]`; the claimant then finishes the claim (re-publishes * the spot under their key). Distinct from `verified` (which vouches for the * spot itself, not who owns it). */ export const MODERATION_LABEL_CLAIM_APPROVED = 'claim-approved'; /** * A claimant's own signed ownership claim (NIP-32 kind 1985). Kept in a SEPARATE * namespace from moderation so `useAdminModeration` never reads non-admin labels * as verdicts. */ export const SPOT_CLAIM_NAMESPACE = 'org.skatespots.spotclaim'; export const SPOT_CLAIM_LABEL = 'claim'; /** 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). 35006 is crews. */ export const LAB_KINDS = [ 35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013, 35014, 35015, 35016, ] 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}`; } /** * A join-request to a Lab COLLECTIVE (studio / build team / etc.) — someone * asks to be added to the collective's roster. Mirrors the crew join-request: * addressable (keyed by collective + requester so re-requesting replaces), * `p`-tagged to the collective owner so it surfaces as a notification. The owner * adds the member via their normal Lab-profile editor (only they can edit their * own event), so there's no forged-membership risk. */ export interface CollectiveJoinRequest { id: string; requester: string; // pubkey asking to join collectiveCoord: string; // e.g. 35003:pubkey:d collectiveName: string; note: string; // optional personal message recipients: string[]; // owner pubkey(s) addressed (from `p` tags) createdAt: number; event: NostrEvent; } /** Lab craft kinds that can be collectives (all of them carry entityType). */ const COLLECTIVE_CAPABLE_KINDS = new Set([ SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_DESIGNER_KIND, ]); export function parseCollectiveJoinRequest(event: NostrEvent): CollectiveJoinRequest | null { if (event.kind !== COLLECTIVE_JOIN_REQUEST_KIND) return null; const collectiveCoord = event.tags.find(([n]) => n === 'a')?.[1]; if (!collectiveCoord) return null; const c = parseAddrCoord(collectiveCoord); if (!c || !COLLECTIVE_CAPABLE_KINDS.has(c.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, collectiveCoord, collectiveName: event.tags.find(([n]) => n === 'collective_name')?.[1] ?? 'a collective', note: event.content.slice(0, 500), recipients, createdAt: event.created_at, event, }; } /** Deterministic d-tag for a collective join-request: coord + requester. */ export function collectiveJoinRequestD(collectiveCoord: string, requesterPubkey: string): string { return `${collectiveCoord}:${requesterPubkey}`; } /** * An admin's endorsement of a join-request (kind 9087). Since only the founder * can edit the crew roster, an admin can't add a member directly — but they can * publicly *vouch* for a request. The founder sees these endorsements on the * request card and ratifies with one click. Public + `p`-tagged to the founder * so it surfaces in their bell as a stronger signal. */ export interface CrewEndorsement { id: string; endorser: string; // admin pubkey who vouched requester: string; // pubkey being vouched for requestId: string; // the join-request event id being endorsed crewCoord: string; // 35006:pubkey:d note: string; // optional "why" createdAt: number; event: NostrEvent; } export function parseCrewEndorsement(event: NostrEvent): CrewEndorsement | null { if (event.kind !== CREW_ENDORSEMENT_KIND) return null; const requestId = event.tags.find(([n]) => n === 'e')?.[1]; const crewCoord = event.tags.find(([n]) => n === 'a')?.[1]; const requester = event.tags.find(([n, , , role]) => n === 'p' && role === 'requester')?.[1] ?? event.tags.find(([n]) => n === 'p')?.[1]; if (!requestId || !crewCoord || !requester) return null; const c = parseAddrCoord(crewCoord); if (!c || c.kind !== SKATE_CREW_KIND) return null; if (!/^[0-9a-f]{64}$/i.test(requester)) return null; return { id: event.id, endorser: event.pubkey, requester: requester.toLowerCase(), requestId, crewCoord, note: event.content.slice(0, 300), createdAt: event.created_at, event, }; } /** * Validate a CLINK Offer code (`noffer1…`) — a Nostr-native, self-custodial * static payment code (see https://github.com/shocknet/CLINK, kind 21001). * Full **bech32 + TLV** validation (checksum included) lives in * `@/lib/clink`; a bare regex used to accept typo'd codes. */ export function isValidNoffer(value: string | undefined | null): value is string { return isValidClinkOffer(value); } /** * 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; const normalized = value.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, ''); return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(normalized); } // ─── 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', labelKey: 'skaterLabelsUi.stance.regular', emoji: '🛹' }, goofy: { label: 'Goofy', labelKey: 'skaterLabelsUi.stance.goofy', emoji: '🤙' }, }; export const SKATER_STYLES: Record = { street: { label: 'Street', labelKey: 'skaterLabelsUi.style.street', emoji: '🛹' }, park: { label: 'Park', labelKey: 'skaterLabelsUi.style.park', emoji: '🏞️' }, vert: { label: 'Vert', labelKey: 'skaterLabelsUi.style.vert', emoji: '📐' }, transition: { label: 'Transition', labelKey: 'skaterLabelsUi.style.transition', emoji: '🌊' }, bowl: { label: 'Bowl', labelKey: 'skaterLabelsUi.style.bowl', emoji: '🥣' }, cruising: { label: 'Cruising', labelKey: 'skaterLabelsUi.style.cruising', emoji: '🛴' }, downhill: { label: 'Downhill', labelKey: 'skaterLabelsUi.style.downhill', emoji: '⛰️' }, freestyle: { label: 'Freestyle', labelKey: 'skaterLabelsUi.style.freestyle', emoji: '🕺' }, ledges: { label: 'Ledges', labelKey: 'skaterLabelsUi.style.ledges', emoji: '🧱' }, stairs: { label: 'Stairs', labelKey: 'skaterLabelsUi.style.stairs', emoji: '🪜' }, diy: { label: 'DIY', labelKey: 'skaterLabelsUi.style.diy', emoji: '🔨' }, }; export const SKATER_LEVELS: Record = { grom: { label: 'Grom', labelKey: 'skaterLabelsUi.level.grom', emoji: '🌱', color: '#22c55e' }, intermediate: { label: 'Intermediate', labelKey: 'skaterLabelsUi.level.intermediate', emoji: '⚡', color: '#3b82f6' }, advanced: { label: 'Advanced', labelKey: 'skaterLabelsUi.level.advanced', emoji: '🔥', color: '#f59e0b' }, ripper: { label: 'Ripper', labelKey: 'skaterLabelsUi.level.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'; /** Weather-protection level: outdoor (default) | partial | covered | indoor. */ shelter?: 'outdoor' | 'partial' | 'covered' | 'indoor'; contactInfo?: string; createdAt: number; /** If this spot is a claimed version of another, the ORIGINAL coordinate (`30818:pubkey:d`). */ claimedFrom?: string; /** Claim provenance: `abandoned` | `transfer` | `private-approved`. */ claimReason?: string; 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 nip05?: string; // Shop NIP-05 identifier (first handle; verified against the owner pubkey) nip05s: string[]; // ALL shop NIP-05 handles (each verified against the owner pubkey) 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 sponsors?: EventSponsor[]; createdAt: number; event: NostrEvent; } /** Event sponsor (mirrors TourSponsor). Rendered as a logo strip on the event. */ export interface EventSponsor { name: string; logo?: string; url?: string; tier?: 'title' | 'gold' | 'silver' | 'partner'; } /** Sponsor tier display metadata (ordering + label). */ export const EVENT_SPONSOR_TIERS: Record< NonNullable, { label: string; labelKey: string; order: number } > = { title: { label: 'Title Sponsor', labelKey: 'skaterLabelsUi.sponsor.title', order: 0 }, gold: { label: 'Gold', labelKey: 'skaterLabelsUi.sponsor.gold', order: 1 }, silver: { label: 'Silver', labelKey: 'skaterLabelsUi.sponsor.silver', order: 2 }, partner: { label: 'Partner', labelKey: 'skaterLabelsUi.sponsor.partner', order: 3 }, }; 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; // Shelter / weather-protection level. Accept only known values; anything else // (or absent) falls back to undefined so the UI can treat it as outdoor. const shelterRaw = event.tags.find(([name]) => name === 'shelter')?.[1]; const shelter = (['outdoor', 'partial', 'covered', 'indoor'] as const).includes( shelterRaw as 'outdoor' | 'partial' | 'covered' | 'indoor', ) ? (shelterRaw as 'outdoor' | 'partial' | 'covered' | 'indoor') : undefined; const contactInfo = event.tags.find(([name]) => name === 'contact')?.[1]; const claimedFromRaw = event.tags.find(([name]) => name === 'claim-from')?.[1]; const claimReason = event.tags.find(([name]) => name === 'claim-reason')?.[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, shelter, contactInfo: clampStr(contactInfo, 200), // Only a coordinate is a usable provenance pointer; legacy events stored the // original author's pubkey, which is too ambiguous to resolve — ignore it. claimedFrom: claimedFromRaw?.startsWith(`${SKATE_SPOT_KIND}:`) ? claimedFromRaw : undefined, claimReason: clampStr(claimReason, 40), 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; // Shop NIP-05 handles (mirrors crews): repeatable, verified against the owner. const nip05s = [...new Set( event.tags .filter(([tagName, value]) => tagName === 'nip05' && !!value?.trim()) .map(([, value]) => value.trim().toLowerCase()) .filter((v) => /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(v)), )].slice(0, 5); const nip05 = nip05s[0]; 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, nip05, nip05s, 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; // Reject a malformed timestamp — NaN in `start` poisons sorting and date // rendering for the whole feed. const startNum = Number(start); if (!Number.isFinite(startNum) || startNum <= 0) 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: startNum, end: end && Number.isFinite(Number(end)) ? Number(end) : undefined, geohash, location, image, types, featuredUntil: parseFeaturedUntil(event), sponsors: parseEventSponsors(event), createdAt: event.created_at, event, }; } /** * Parse event sponsors from tags. Format (mirrors tours): * ["sponsor", "", "", "", ""] */ export function parseEventSponsors(event: NostrEvent): EventSponsor[] | undefined { const tags = event.tags.filter(([name]) => name === 'sponsor'); if (tags.length === 0) return undefined; const validTiers = new Set(['title', 'gold', 'silver', 'partner']); const sponsors: EventSponsor[] = []; for (const tag of tags) { const name = clampStr(tag[2], MAX_TITLE_LEN)?.trim(); if (!name) continue; const tier = validTiers.has(tag[1]) ? (tag[1] as EventSponsor['tier']) : 'partner'; sponsors.push({ tier, name, logo: tag[3] || undefined, url: tag[4] || undefined, }); } if (sponsors.length === 0) return undefined; // Sort by tier importance. return sponsors.sort( (a, b) => EVENT_SPONSOR_TIERS[a.tier ?? 'partner'].order - EVENT_SPONSOR_TIERS[b.tier ?? 'partner'].order, ); } /** Build sponsor tags for publishing an event. */ export function eventSponsorTags(sponsors: EventSponsor[]): string[][] { return sponsors .filter((s) => s.name.trim()) .map((s) => [ 'sponsor', s.tier ?? 'partner', s.name.trim(), s.logo?.trim() ?? '', s.url?.trim() ?? '', ]); } // ─── NIP-52 Calendar Event RSVP (kind 31925) ──────────────────────────────── export type RsvpStatus = 'accepted' | 'tentative' | 'declined'; export interface EventRsvp { id: string; /** The pubkey of the person RSVP'ing. */ pubkey: string; /** The RSVP's own `d` identifier. */ identifier: string; /** Coordinate of the calendar event being responded to ("31923:pubkey:d"). */ eventCoord: string; status: RsvpStatus; /** Optional free-form note. */ note?: string; createdAt: number; event: NostrEvent; } /** * Parse a NIP-52 RSVP (kind 31925). Requires a valid `a` coordinate to a * calendar event and a recognized `status`. */ export function parseEventRsvp(event: NostrEvent): EventRsvp | null { if (event.kind !== CALENDAR_RSVP_KIND) return null; const d = event.tags.find(([name]) => name === 'd')?.[1]; const eventCoord = event.tags.find(([name]) => name === 'a')?.[1]; const status = event.tags.find(([name]) => name === 'status')?.[1] as RsvpStatus | undefined; if (!d || !eventCoord || !status) return null; if (!['accepted', 'tentative', 'declined'].includes(status)) return null; // Must reference a calendar event coordinate. if (!eventCoord.startsWith(`${CALENDAR_EVENT_KIND}:`)) return null; return { id: event.id, pubkey: event.pubkey, identifier: d, eventCoord, status, note: event.content?.slice(0, MAX_DESCRIPTION_LEN) || undefined, createdAt: event.created_at, event, }; } // ─── Event Update (kind 7103) — host broadcast pinned to a calendar event ──── export type EventUpdateKind = 'update' | 'reschedule' | 'relocation' | 'cancelled'; export interface EventUpdate { id: string; pubkey: string; /** Coordinate of the calendar event ("31923:pubkey:d"). */ eventCoord: string; /** The calendar event's raw id, if referenced. */ eventId?: string; message: string; /** Category of update, for iconography / emphasis. */ updateKind: EventUpdateKind; /** * Who this update is authoritatively from, relative to the event it targets: * - 'host' → signed by the event's own author (coordinate pubkey) * - 'admin' → signed by a SkateSpots admin acting as a moderator * Any other author is untrusted and MUST be discarded by callers (a random * npub could otherwise forge a fake "CANCELLED" update). */ authorRole: 'host' | 'admin'; createdAt: number; event: NostrEvent; } /** * Parse a host Event Update (kind 7103). Requires an `a` coordinate to a * calendar event and non-empty content. * * SECURITY: the update is only trusted if its author is either the event's own * host (derived from the `a` coordinate) or a SkateSpots admin. Updates from any * other pubkey return `null` so a stranger can't spoof a cancellation. */ export function parseEventUpdate(event: NostrEvent): EventUpdate | null { if (event.kind !== EVENT_UPDATE_KIND) return null; const eventCoord = event.tags.find(([name]) => name === 'a')?.[1]; if (!eventCoord || !eventCoord.startsWith(`${CALENDAR_EVENT_KIND}:`)) return null; const message = event.content?.trim(); if (!message) return null; const rawKind = event.tags.find(([name]) => name === 'update-kind')?.[1]; const updateKind: EventUpdateKind = rawKind === 'reschedule' || rawKind === 'relocation' || rawKind === 'cancelled' ? rawKind : 'update'; // SECURITY: only trust updates from the event's host (the pubkey embedded in // the `a` coordinate) or a SkateSpots admin. Anyone else is forging. const hostPubkey = eventCoord.split(':')[1]; let authorRole: 'host' | 'admin'; if (event.pubkey === hostPubkey) { authorRole = 'host'; } else if (isAdminPubkey(event.pubkey)) { authorRole = 'admin'; } else { return null; } return { id: event.id, pubkey: event.pubkey, eventCoord, eventId: event.tags.find(([name]) => name === 'e')?.[1], message: message.slice(0, MAX_DESCRIPTION_LEN), updateKind, authorRole, 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; labelKey: string; emoji: string }> { const allStreetFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'street') .map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], emoji: v.emoji })); const allTransitionFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'transition') .map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], emoji: v.emoji })); const allParkFeatures = Object.entries(SPOT_FEATURES) .filter(([, v]) => v.category === 'park') .map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], 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; labelKey: string; emoji: string; category: string }> { return Object.entries(SPOT_FEATURES).map(([key, v]) => ({ key, label: v.label, labelKey: SPOT_FEATURE_KEYS[key], 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' }, }; /** * Display metadata for a spot's shelter / weather-protection level. `short` is * used in tight spots (cards, map popups); `label` in the detail view. Only * sheltered spots are worth badging — 'outdoor' is the default and returns no * badge, so we don't clutter the vast majority of street spots. */ export const SHELTER_INFO: Record< 'partial' | 'covered' | 'indoor', { emoji: string; label: string; short: string; color: string } > = { partial: { emoji: '🌤️', label: 'Partly Covered', short: 'Partly Covered', color: '#38bdf8' }, covered: { emoji: '🏠', label: 'Covered / Roofed', short: 'Covered', color: '#22d3ee' }, indoor: { emoji: '🏢', label: 'Indoor', short: 'Indoor', color: '#818cf8' }, }; 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', }; // ----- Clothing designer (custom clothes, team kits, apparel) ----- // What they make export const DESIGNER_SERVICE_LABELS: Record = { tees: 'T-Shirts', hoodies: 'Hoodies & Sweats', pants: 'Pants & Shorts', jackets: 'Jackets & Outerwear', kits: 'Team Kits & Jerseys', caps: 'Caps & Beanies', socks: 'Socks', accessories: 'Bags & Accessories', footwear: 'Footwear & Shoes', patches: 'Patches & Appliqué', griptape: 'Griptape & Deck Apparel', onepiece: 'One-of-One / Upcycled', }; export const DESIGNER_SERVICE_DESCRIPTIONS: Record = { tees: 'Custom tees and tops', hoodies: 'Hoodies, crews and sweatpants', pants: 'Work pants, cargos and shorts', jackets: 'Coaches, shells and work jackets', kits: 'Team kits, jerseys and uniforms', caps: 'Caps, beanies and headwear', socks: 'Crew socks and knitwear', accessories: 'Bags, belts and small goods', footwear: 'Custom shoes and footwear', patches: 'Embroidered patches and appliqué', griptape: 'Griptape art and deck apparel', onepiece: 'Single pieces and upcycled garments', }; // How they make it export const DESIGNER_METHOD_LABELS: Record = { screenprint: 'Screen Print', dtg: 'DTG / Direct-to-Garment', embroidery: 'Embroidery', cutsew: 'Cut & Sew', sublimation: 'Sublimation', heattransfer: 'Heat Transfer / Vinyl', garmentdye: 'Garment Dye', knit: 'Knitwear', patchwork: 'Patchwork', allover: 'All-over Print', }; // Materials export const DESIGNER_MATERIAL_LABELS: Record = { cotton: 'Cotton', heavyweight: 'Heavyweight', organic: 'Organic / Sustainable', fleece: 'Fleece', denim: 'Denim', canvas: 'Canvas', nylon: 'Nylon / Technical', wool: 'Merino / Wool', leather: 'Leather', recycled: 'Recycled / Deadstock', }; // ----- 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', 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', }; // ----- Event Organizers / Hosts (kind 35013) ----- // The kinds of events the organizer puts on. export const ORGANIZER_EVENT_LABELS: Record = { street_jam: 'Street Jam', best_trick: 'Best Trick', game_of_skate: 'Game of S.K.A.T.E.', contest: 'Contest / Comp', bowl: 'Bowl / Vert', mini_ramp: 'Mini Ramp', park: 'Park Session', demo: 'Pro Demo', clinic: 'Clinic / Workshop', meetup: 'Meetup / Cruise', downhill: 'Downhill / Race', slalom: 'Slalom', kids: 'Kids / Youth', charity: 'Charity / Fundraiser', tour_stop: 'Tour Stop', premiere: 'Video Premiere', market: 'Market / Art Show', }; // What the organizer actually takes care of. export const ORGANIZER_SERVICE_LABELS: Record = { organizing: 'Full Organizing', hosting: 'Hosting / MC', permits: 'Permits & Council', sponsorship: 'Sponsorship / Brand Deals', promotion: 'Promotion & Marketing', livestream: 'Livestream / Coverage', sound: 'Sound & Music', ramps: 'Ramps & Obstacles', safety: 'Safety & First Aid', vendors: 'Food & Vendors', media: 'Photo / Video Coverage', registration: 'Sign-ups & Brackets', }; // How big the events they run are. export const ORGANIZER_SCALE_LABELS: Record = { grassroots: 'Grassroots', local: 'Local', regional: 'Regional', national: 'National', international: 'International', pro: 'Pro Circuit', }; // ----- Barbers / Hairstylists (kind 35014) ----- // What the barber offers. export const BARBER_SERVICE_LABELS: Record = { haircut: 'Haircut', fade: 'Fade', beard: 'Beard Trim', lineup: 'Line-up / Shape-up', colour: 'Colour & Dye', kids: "Kids' Cuts", }; export const BARBER_SERVICE_DESCRIPTIONS: Record = { haircut: 'A classic cut — clippers and scissors.', fade: 'A graduated fade — low, mid or high.', beard: 'Beard trim, shape and tidy-up.', lineup: 'Clean edges around the hairline and neck.', colour: 'Colour, bleach, dye and toning.', kids: 'Patient cuts for younger skaters.', }; // Signature styles. export const BARBER_STYLE_LABELS: Record = { skin_fade: 'Skin Fade', taper: 'Taper', buzz: 'Buzz Cut', mohawk: 'Mohawk', mullet: 'Mullet', curly: 'Curls / Afro', long: 'Long Hair / Layers', classic: 'Classic Scissor Cut', }; // Where they cut hair. export const BARBER_SETTING_LABELS: Record = { shop: 'Shop / Barbershop', mobile: 'Mobile / House Calls', event: 'Skate Events & Comps', }; // ----- Transport / Drivers (kind 35016) ----- /** Vehicle classes a transport provider can offer. */ export const TRANSPORT_VEHICLE_LABELS: Record = { car: 'Car', taxi: 'Taxi / Rideshare', van: 'Van / Minibus', moto: 'Motorcycle', helicopter: 'Helicopter', plane: 'Plane', boat: 'Boat', other: 'Other vehicle', }; /** What they move. */ export const TRANSPORT_SERVICE_LABELS: Record = { people: 'People', goods: 'Goods / Cargo', gear: 'Skate gear', airport: 'Airport transfer', venue: 'Venue / Event transfer', tours: 'Tours / Road trips', logistics: 'Event logistics', other: 'Other service', }; /** Practical capabilities, self-attested. */ export const TRANSPORT_FEATURE_LABELS: Record = { cargo: 'Cargo space', bike_board: 'Bike / board rack', wheelchair: 'Wheelchair access', pets: 'Pets welcome', border: 'Cross-border', night: 'Night driving', covered: 'Covered / weatherproof', group: 'Large groups', }; export const TRANSPORT_CAPACITY_LABELS: Record = { solo: '1–3 people', small_group: '4–7 people', large_group: '8+ people', van_load: 'Van load', freight: 'Freight / cargo', }; export const TRANSPORT_COVERAGE_LABELS: Record = { local: 'Local', regional: 'Regional', national: 'National', international: 'International', cross_border: 'Cross-border routes', }; // ----- 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 (first handle; verified against founder pubkey) nip05s: string[]; // ALL crew NIP-05 handles (each verified against the 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 cordnGroup?: string; // Cordn group ref/link for the crew's MLS chat (from Staircase/Cordn web) (`cordn` tag) 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 = // ── Lab crafts (mirror the Lab profession kinds 35001–35012) ── | 'filmer' // 35002 | 'photographer' // 35004 | 'editor' // 35003 | 'coach' // 35001 | 'builder' // 35005 | 'artist' // 35007 (designer / board graphics) | 'graffiti' // 35008 | 'tattoo' // 35009 | 'trainer' // 35010 (physio / strength) | 'musician' // 35011 | 'judge' // 35012 // ── Crew-specific roles (no Lab equivalent) ── | 'manager' | 'og'; export interface CrewMemberRole { pubkey: string; role: CrewRole; } /** * Human labels + emoji for each crew role (skate-native language). Lab crafts * reuse the same emoji as their Lab profession so the language stays consistent * across the app; `labKind` links a role back to its Lab profile kind (if any) * so we can cross-link "this filmer has a Lab profile" later. */ export const CREW_ROLE_META: Record = { filmer: { label: 'Filmer', emoji: '🎥', labKind: SKATE_FILMER_KIND }, photographer: { label: 'Photographer', emoji: '📸', labKind: SKATE_PHOTOGRAPHER_KIND }, editor: { label: 'Editor', emoji: '✂️', labKind: SKATE_EDITOR_KIND }, coach: { label: 'Coach', emoji: '🎓', labKind: SKATE_COACH_KIND }, builder: { label: 'Builder', emoji: '🔨', labKind: SKATE_BUILDER_KIND }, artist: { label: 'Artist', emoji: '🎨', labKind: SKATE_ARTIST_KIND }, graffiti: { label: 'Graffiti', emoji: '🖌️', labKind: SKATE_GRAFFITI_KIND }, tattoo: { label: 'Tattoo', emoji: '💉', labKind: SKATE_TATTOO_KIND }, trainer: { label: 'Trainer', emoji: '💪', labKind: SKATE_TRAINER_KIND }, musician: { label: 'Musician', emoji: '🎵', labKind: SKATE_MUSICIAN_KIND }, judge: { label: 'Judge', emoji: '⚖️', labKind: SKATE_JUDGE_KIND }, manager: { label: 'Team Manager', emoji: '📋' }, og: { label: 'OG', emoji: '👑' }, }; /** All assignable crew roles, in display order (media crafts → Lab crafts → crew-specific). */ export const CREW_ROLE_ORDER: CrewRole[] = [ 'filmer', 'photographer', 'editor', 'coach', 'builder', 'artist', 'graffiti', 'tattoo', 'trainer', 'musician', 'judge', '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; /** Session formats taught (1-on-1, group, camp, …). */ formats: string[]; /** Levels taught (beginner → competition). */ levels: string[]; /** Age groups (kids / teens / adults / seniors). */ ageGroups: string[]; /** Self-attested credentials (first-aid, safeguarding, …). */ credentials: string[]; /** Optional free-form credential note (names an exact cert / issuer). */ certNote?: string; /** Languages spoken. */ languages: string[]; /** Availability windows (weekends, evenings, …). */ availability: string[]; /** Optional booking link. */ booking?: string; /** Coaching portfolio gallery image URLs (repeatable `photo` tags). */ photos: string[]; /** Optional highlight-reel video URL (YouTube / Instagram). */ video?: string; /** Structured rate amount (string to preserve formatting), optional. */ rateAmount?: string; /** Structured rate currency (`sats` / `USD` / `EUR` / `GBP`). */ rateCurrency?: string; /** Structured rate billing unit (`hour` / `session` / …). */ rateUnit?: string; /** Declared accepted payment methods (`cash` / `lightning` / `cashu` / …). */ paymentMethods: string[]; /** Optional free-form payment note. */ paymentNote?: string; /** Optional booking fee amount (paid directly to the coach — never held by us). */ bookingFee?: string; /** Currency for `bookingFee`. */ bookingFeeCurrency?: string; /** Sliding scale / pay-what-you-can. */ payWhatYouCan?: boolean; 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 type SkateDesigner = SkateArtist; export type SkateTransport = SkateArtist; 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); // Repeatable, deduped string tags (formats/levels/ages/credentials/languages/availability). const values = (name: string): string[] => [ ...new Set( event.tags.filter(([n, v]) => n === name && !!v?.trim()).map(([, v]) => v.trim().toLowerCase()), ), ]; const formats = values('format'); const levels = values('level'); const ageGroups = values('age'); const credentials = values('cert'); const certNote = event.tags.find(([name]) => name === 'cert_note')?.[1]; // Languages are proper nouns — preserve case (no lowercasing). const languages = event.tags .filter(([n, v]) => n === 'language' && !!v?.trim()) .map(([, v]) => v.trim()) .filter((v, i, arr) => arr.indexOf(v) === i); const availability = values('availability'); const booking = event.tags.find(([name]) => name === 'booking')?.[1]; // Portfolio gallery (`photo` tags) — URLs are case-sensitive, so no lowercasing. const photos = event.tags .filter(([n, v]) => n === 'photo' && !!v?.trim()) .map(([, v]) => v.trim()) .filter((v, i, arr) => arr.indexOf(v) === i) .slice(0, 12); const video = event.tags.find(([name]) => name === 'video')?.[1]; const rateAmount = event.tags.find(([name]) => name === 'rate_amount')?.[1]; const rateCurrency = event.tags.find(([name]) => name === 'rate_currency')?.[1]; const rateUnit = event.tags.find(([name]) => name === 'rate_unit')?.[1]; const paymentMethods = values('payment_method'); const paymentNote = event.tags.find(([name]) => name === 'payment_note')?.[1]; const bookingFee = event.tags.find(([name]) => name === 'booking_fee')?.[1]; const bookingFeeCurrency = event.tags.find(([name]) => name === 'booking_fee_currency')?.[1]; const payWhatYouCan = event.tags.some(([name, value]) => name === 'pay_what_you_can' && value === 'true'); 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, formats, levels, ageGroups, credentials, certNote, languages, availability, booking, photos, video, rateAmount, rateCurrency, rateUnit, paymentMethods, paymentNote, bookingFee, bookingFeeCurrency, payWhatYouCan, 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 parseSkateDesigner(event: NostrEvent): SkateDesigner | null { if (event.kind !== SKATE_DESIGNER_KIND) return null; const base = parseCraftBase(event, 'skateclothing'); if (!base) return null; return base; } export function parseSkateTransport(event: NostrEvent): SkateTransport | null { if (event.kind !== SKATE_TRANSPORT_KIND) return null; const base = parseCraftBase(event, 'skatetransport'); 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; // Crew NIP-05 handles. Unlike a kind-0 profile (one `nip05` string), a crew is // our own tag convention, so it can carry several — each verified against the // founder's pubkey. Normalise, dedupe, drop malformed, cap at 5. const nip05s = [...new Set( event.tags .filter(([name, value]) => name === 'nip05' && !!value?.trim()) .map(([, value]) => value.trim().toLowerCase()) .filter((v) => /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(v)), )].slice(0, 5); const nip05 = clampStr(nip05s[0], 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. // Cordn group link for the crew's MLS chat (optional): ['cordn', ]. const cordnGroup = event.tags.find(([name, value]) => name === 'cordn' && !!value?.trim())?.[1]?.trim() || undefined; 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, nip05s, noffer, members, admins, spotRefs, labRefs, goalRef, lookingFor, sponsorRef, coordinator, cordnGroup, 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 mpLat = Number(meetingCoordsTag?.[1]); const mpLon = Number(meetingCoordsTag?.[2]); const meetingPointCoords = meetingCoordsTag && Number.isFinite(mpLat) && Number.isFinite(mpLon) ? { lat: mpLat, lon: mpLon } : undefined; const image = event.tags.find(([name]) => name === 'image')?.[1]; const durationTag = event.tags.find(([name]) => name === 'duration'); const durationNum = durationTag ? Number(durationTag[1]) : undefined; const duration = durationNum !== undefined && Number.isFinite(durationNum) ? durationNum : 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, t?: (key: string) => string, ): string { if (!price) { if (priceType === 'free') return t ? t('labelsUi.price.free') : 'FREE'; if (priceType === 'trade') return t ? t('labelsUi.price.trade') : 'Trade'; if (priceType === 'offer') return t ? t('labelsUi.price.makeOffer') : 'Make Offer'; return t ? t('labelsUi.price.offer') : '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; /** How the deal is fulfilled: local pickup, shipping, or either. */ fulfilment?: 'pickup' | 'ships' | 'both'; /** Who pays postage, when the deal ships. */ shipping?: 'buyer' | 'seller' | 'split'; /** Seller asks the buyer to settle via Cashu escrow. */ escrow?: 'required'; /** NIP-13 proof-of-work difficulty (leading zero bits) on the event id. */ powDifficulty?: number; 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 fulfilment = event.tags.find(([name]) => name === 'fulfilment')?.[1] as MarketplaceItem['fulfilment']; const shipping = event.tags.find(([name]) => name === 'shipping')?.[1] as MarketplaceItem['shipping']; const escrow = event.tags.find(([name]) => name === 'escrow')?.[1] as MarketplaceItem['escrow']; const powDifficulty = event.tags.some(([name]) => name === 'nonce') ? countLeadingZeroBits(event.id) : undefined; 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, fulfilment, shipping, escrow, powDifficulty, 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], }; } export interface SkateOrganizer { id: string; pubkey: string; identifier: string; featuredUntil?: number; title: string; // organizer / crew name bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // event types + services + scale tags experience?: string; // years organizing rate?: string; // fee / budget for putting on an event phone?: string; email?: string; website?: string; portfolio?: string; // past events gallery / results link social?: string; eventsHosted?: string; // freeform count / notable events run noffer?: string; entityType: EntityType; members: string[]; crewRef?: string; createdAt: number; event: NostrEvent; } export function parseSkateOrganizer(event: NostrEvent): SkateOrganizer | null { if (event.kind !== SKATE_ORGANIZER_KIND) return null; const base = parseCraftBase(event, 'skateorganizer'); if (!base) return null; return { ...base, eventsHosted: event.tags.find(([name]) => name === 'events_hosted')?.[1], }; } export interface SkateBarber { id: string; pubkey: string; identifier: string; featuredUntil?: number; title: string; // barber / shop name bio: string; geohash?: string; location?: string; image?: string; skills: string[]; // services + styles + settings tags experience?: string; // years cutting rate?: string; // price / cut phone?: string; email?: string; website?: string; portfolio?: string; // Instagram / gallery of cuts social?: string; studio?: string; // shop / studio name booksOpen?: boolean; // currently taking bookings noffer?: string; entityType: EntityType; members: string[]; crewRef?: string; createdAt: number; event: NostrEvent; } export function parseSkateBarber(event: NostrEvent): SkateBarber | null { if (event.kind !== SKATE_BARBER_KIND) return null; const base = parseCraftBase(event, 'skatebarber'); 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'), }; }