SkateSpots/src/lib/types.ts

4106 lines
150 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import type { NostrEvent } from '@nostrify/nostrify';
import ngeohash from 'ngeohash';
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", "<kind>:<founderPubkey>:<d-tag>"] // 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", <level>]` 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<MemberVisibility, string> = {
public: 'Public',
crew: 'Crew only',
};
export const MEMBER_VISIBILITY_DESCRIPTIONS: Record<MemberVisibility, string> = {
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", <postId>] // the moderated post
* ["a", <crewCoord>] // 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", <pubkey>, <relay-hint?>, "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", "<unix-seconds-expiry>"]` 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: `<kind>:<pubkey>:<d>`.
*/
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", "<crewCoord>:<inviteePubkey>"] // unique per crew+invitee
// ["p", "<inviteePubkey>"] // recipient — queryable by them
// ["a", "<crewCoord>"] // the crew (35006:pubkey:d)
// ["crew_name", "<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<number>([
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<SkaterStance, { label: string; labelKey: string; emoji: string }> = {
regular: { label: 'Regular', labelKey: 'skaterLabelsUi.stance.regular', emoji: '🛹' },
goofy: { label: 'Goofy', labelKey: 'skaterLabelsUi.stance.goofy', emoji: '🤙' },
};
export const SKATER_STYLES: Record<SkaterStyle, { label: string; labelKey: string; emoji: string }> = {
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<SkaterLevel, { label: string; labelKey: string; emoji: string; color: string }> = {
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<EventSponsor['tier']>,
{ 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", "<tier>", "<name>", "<logo-url>", "<website-url>"]
*/
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<string, string> = {
// Categories
street: 'Street',
transition: 'Transition',
park: 'Park',
plaza: 'Plaza',
// Features (for display/filtering)
wallrides: 'Wallrides',
stairs: 'Stairs & Handrails',
rails: 'Rails',
ledges: 'Ledges & Curbs',
gaps: 'Gaps',
flatground: 'Flatground',
'manual-pads': 'Manual Pads',
'half-pipes': 'Half Pipes',
'quarter-pipes': 'Quarter Pipes',
'mini-ramps': 'Mini Ramps',
bowls: 'Bowls',
vert: 'Vert Ramps',
'mega-ramps': 'Mega Ramps',
hubbas: 'Hubbas',
'euro-gaps': 'Euro Gaps',
banks: 'Banks',
'spine-ramps': 'Spine Ramps',
'roll-ins': 'Roll-ins',
hips: 'Hips',
volcano: 'Volcano',
pyramids: 'Pyramids / Funboxes',
'snake-runs': 'Snake Runs',
'pump-track': 'Pump Track Rollers',
'flat-bars': 'Flat Bars',
// Legacy (deprecated but kept for backwards compat)
diy: 'DIY',
rail: 'Rail',
ledge: 'Ledge',
gap: 'Gap',
bowl: 'Bowl',
stall: 'Stall',
slappy: 'Slappy',
};
export const ACCESS_TYPE_INFO = {
public: { emoji: '🌍', label: 'Public - Open to Everyone', color: '#ff0040' },
private: { emoji: '🔒', label: 'Private Property - Permission Needed', color: '#ff6600' },
locals: { emoji: '👥', label: 'Ask Locals First', color: '#ccff00' },
};
/**
* 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<string, string> = {
contest: 'Contest',
demo: 'Demo',
session: 'Session',
workshop: 'Workshop',
premiere: 'Premiere',
tradeshow: 'Trade Show',
gameskate: 'Game of Skate',
// Music & Culture
concert: '🎸 Concert',
jam: '🎵 Jam',
festival: '🎪 Festival',
popup: '🎯 Pop-Up',
};
export const COACH_SKILL_LABELS: Record<string, string> = {
basics: 'Basics',
ollies: 'Ollies',
kickflips: 'Kickflips',
grinds: 'Grinds & Slides',
manuals: 'Manuals',
inverts: 'Inverts',
vert: 'Vert/Ramp',
bowl: 'Bowl',
street: 'Street',
freestyle: 'Freestyle',
safety: 'Safety',
diy: 'DIY Skate Modules',
};
export const COACH_SKILL_DESCRIPTIONS: Record<string, string> = {
basics: 'Pushing, stance, balance, turning',
ollies: 'Ollie and variations (nollie, fakie)',
kickflips: 'Kickflip, heelflip, and variations',
grinds: '50-50, 5-0, boardslide, and more',
manuals: 'Manual, nose manual, combos',
inverts: 'Invert, handplant, eggplant, andrecht',
vert: 'Half-pipe, quarter-pipe, drop-ins',
bowl: 'Bowl carving, pumping, lines',
street: 'Street obstacles and creativity',
freestyle: 'Flatground tricks and flow',
safety: 'How to fall, protective gear, injury prevention',
diy: 'Building ramps, rails, ledges, and other skate obstacles',
};
// Filming service types
export const FILMING_SERVICE_LABELS: Record<string, string> = {
miniclips: 'Mini Clips',
longparts: 'Long Video Parts',
koth: 'King of the Road Style',
documentaries: 'Documentaries',
contests: 'Filming for Contests',
raw: 'Raw Footage',
followlines: 'Follow / Line Filming',
lines: 'Line & B-Roll Filming',
};
export const FILMING_SERVICE_DESCRIPTIONS: Record<string, string> = {
miniclips: '15-60 sec clips for social media',
longparts: '3-10 min full video parts with music',
koth: 'Adventure/challenge style skate videos',
documentaries: 'Skate culture and story-driven content',
contests: 'Professional contest and competition coverage',
raw: 'Unedited multi-angle footage',
followlines: 'Fisheye follow-cam for lines',
lines: 'Long-lens lines and cinematic b-roll',
};
// Filming skills/equipment (video-focused)
export const FILMING_SKILL_LABELS: Record<string, string> = {
vx1000: 'VX1000',
hvx: 'HVX / HPX',
cinema: 'Cinema Camera',
dslr: 'DSLR / Mirrorless',
actioncam: 'GoPro / Action Cam',
phone: 'Phone Filming',
drone: 'Drone / Aerial',
gimbal: 'Gimbal / Stabilizer',
fisheye: 'Fisheye Lens',
longlens: 'Long Lens',
'4k': '4K / High Frame Rate',
editing: 'Video Editing',
color: 'Color Grading',
};
export const FILMING_SKILL_DESCRIPTIONS: Record<string, string> = {
vx1000: 'Classic VX1000 skateboarding aesthetic',
hvx: 'Panasonic HVX/HPX standard-def / HD look',
cinema: 'Cinema camera (FX3, C-series, RED, BMPCC)',
dslr: 'High quality DSLR or mirrorless camera',
actioncam: 'GoPro / action cam POV and mounts',
phone: 'Mobile filming with modern smartphones',
drone: 'Aerial and drone footage',
gimbal: 'Stabilized gimbal movement',
fisheye: 'Wide-angle fisheye lens shots',
longlens: 'Long lens for detailed shots',
'4k': '4K resolution and high frame rate slow-mo',
editing: 'Professional video editing and post-production',
color: 'Color correction and grading',
};
// Video editor service types
export const EDITOR_SERVICE_LABELS: Record<string, string> = {
parts: 'Video Parts',
montages: 'Montages',
reels: 'Reels / Short Clips',
documentaries: 'Documentaries',
contest: 'Contest Edits',
brand: 'Brand / Promo Videos',
motion: 'Motion Graphics',
colorgrade: 'Color Grading Only',
};
export const EDITOR_SERVICE_DESCRIPTIONS: Record<string, string> = {
parts: 'Full skate video parts with soundtrack',
montages: 'Compilation edits from raw footage',
reels: '15–60 sec social media clips & reels',
documentaries: 'Story-driven skate culture films',
contest: 'Recap edits for contests and events',
brand: 'Promotional content for brands and shops',
motion: 'Titles, transitions, effects, and graphics',
colorgrade: 'Colour correction and LUT-based grading',
};
// Hardware / software skills for editors
export const EDITOR_SKILL_LABELS: Record<string, string> = {
premiere: 'Adobe Premiere',
resolve: 'DaVinci Resolve',
finalcut: 'Final Cut Pro',
aftereffects: 'After Effects',
luts: 'Custom LUTs',
vhs: 'VHS / Retro Aesthetic',
'4k': '4K & RAW Workflows',
sound: 'Sound Design / Mix',
};
export const EDITOR_SKILL_DESCRIPTIONS: Record<string, string> = {
premiere: 'Adobe Premiere Pro editing',
resolve: 'DaVinci Resolve editing & grading',
finalcut: 'Final Cut Pro editing',
aftereffects: 'Motion graphics and compositing',
luts: 'Custom colour look development',
vhs: 'VHS glitch, super-8, retro film looks',
'4k': 'High-res RAW/LOG camera workflows',
sound: 'Music sync, SFX, audio mastering',
};
// ─── Photographer taxonomy ──────────────────────────────────────────────────
// Skate photography is a distinct craft from filming — it's shot-, light-,
// and print-oriented. These maps power The Lab's Photographers category.
// What a photographer offers (deliverables / shoot types)
export const PHOTO_SERVICE_LABELS: Record<string, string> = {
action: 'Action / Trick Shots',
sequence: 'Sequence Shots',
portraits: 'Portraits',
editorial: 'Editorial / Magazine',
product: 'Product / Shop Photography',
events: 'Event Coverage',
lifestyle: 'Lifestyle / Documentary',
prints: 'Prints & Zines',
};
export const PHOTO_SERVICE_DESCRIPTIONS: Record<string, string> = {
action: 'Peak-action stills of tricks and lines',
sequence: 'Multi-frame trick sequences',
portraits: 'Rider portraits and team shots',
editorial: 'Magazine-ready editorial spreads',
product: 'Deck, gear and shop product photography',
events: 'Comp, jam and premiere coverage',
lifestyle: 'Behind-the-scenes and culture documentary',
prints: 'Physical prints, zines and photo books',
};
// Camera bodies / formats
export const PHOTO_CAMERA_LABELS: Record<string, string> = {
dslr: 'DSLR',
mirrorless: 'Mirrorless',
medium: 'Medium Format',
film35: '35mm Film',
filmmedium: 'Medium Format Film',
point: 'Point & Shoot',
phone: 'Phone',
};
// Lenses
export const PHOTO_LENS_LABELS: Record<string, string> = {
fisheye: 'Fisheye',
wide: 'Wide Angle',
standard: 'Standard / Prime',
telephoto: 'Telephoto / Long Lens',
macro: 'Macro',
zoom: 'Zoom',
};
// Lighting gear
export const PHOTO_LIGHTING_LABELS: Record<string, string> = {
strobes: 'Studio Strobes',
speedlights: 'Speedlights / Flashes',
profoto: 'Profoto',
elinchrom: 'Elinchrom',
godox: 'Godox',
triggers: 'Remote Triggers',
continuous: 'Continuous / LED',
reflectors: 'Reflectors / Diffusers',
naturallight: 'Natural Light Only',
};
// Support / accessories
export const PHOTO_SUPPORT_LABELS: Record<string, string> = {
tripod: 'Tripod',
monopod: 'Monopod',
lightstands: 'Light Stands',
gimbal: 'Gimbal',
bag: 'Mobile Rig / Bag',
laptop: 'On-site Tethering',
};
// Shooting style
export const PHOTO_STYLE_LABELS: Record<string, string> = {
flashfisheye: 'Flash Fisheye',
natural: 'Natural Light',
bw: 'Black & White',
film: 'Film Aesthetic',
longlens: 'Long Lens',
studio: 'Studio / Lit',
street: 'Street / Documentary',
};
// ----- Builder (ramp / park / DIY builders) -----
// What kind of things they build
export const BUILDER_SERVICE_LABELS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
spray: 'Spray Cans',
rollers: 'Rollers / Emulsion',
markers: 'Markers / Inks',
brush: 'Brush / Paint',
digital: 'Digital / Procreate',
};
// Work context
export const GRAFFITI_CONTEXT_LABELS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
haircut: 'Haircut',
fade: 'Fade',
beard: 'Beard Trim',
lineup: 'Line-up / Shape-up',
colour: 'Colour & Dye',
kids: "Kids' Cuts",
};
export const BARBER_SERVICE_DESCRIPTIONS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<CrewFocus, string> = {
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<CrewFocus, string> = {
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<Exclude<CrewFocus, 'mixed'>, 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<string, CrewFocus> = {
'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", <slug>]` 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<string, { label: string; emoji: string }> =
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<CrewFocus, 'mixed'> {
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", <memberPubkey>, <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<CrewRole, { label: string; emoji: string; labKind?: number }> = {
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<string>(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<string>();
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', <url>, <coordinator-pubkey?>, <operator-pubkey?>]
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', <ref-or-url>].
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<string, string[]> = {
deck: ['7.5"', '7.75"', '8.0"', '8.125"', '8.25"', '8.38"', '8.5"', '8.75"', '9.0"+'],
trucks: ['129', '139', '144', '149', '159'],
wheels: ['50mm', '52mm', '54mm', '56mm', '58mm', '60mm'],
shoes: ['US 6', 'US 6.5', 'US 7', 'US 7.5', 'US 8', 'US 8.5', 'US 9', 'US 9.5', 'US 10', 'US 10.5', 'US 11', 'US 11.5', 'US 12', 'US 13', 'US 14'],
clothing: ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
};
// Truck Heights
export const TRUCK_HEIGHTS = ['Low', 'Mid', 'High', 'Inverted'];
// Wheel Hardness
export const WHEEL_HARDNESS = ['78a', '87a', '92a', '97a', '99a', '101a'];
// Category-specific required fields for validation
export const CATEGORY_REQUIRED_FIELDS: Record<string, { size?: boolean; condition?: boolean }> = {
deck: { size: true, condition: true },
trucks: { size: true, condition: true },
wheels: { size: true, condition: true },
bearings: { condition: true },
shoes: { size: true, condition: true },
clothing: { size: true, condition: true },
protective: { size: false, condition: true },
stickers: { size: false, condition: false },
wax: { size: false, condition: false },
tools: { size: false, condition: true },
griptape: { size: false, condition: true },
rubbrick: { size: false, condition: true },
};
// Report reasons for NIP-56
export const LISTING_REPORT_REASONS = {
category_mismatch: { label: 'Wrong Category', description: 'Item is listed in the wrong category' },
misleading_description: { label: 'Misleading Description', description: 'Description does not match the actual item' },
fake_photos: { label: 'Fake/Stolen Photos', description: 'Photos are stock images or stolen from elsewhere' },
scam: { label: 'Suspected Scam', description: 'This listing appears to be a scam' },
spam: { label: 'Spam/Duplicate', description: 'Spam or duplicate listing' },
inappropriate: { label: 'Inappropriate Content', description: 'Contains inappropriate or offensive content' },
counterfeit: { label: 'Counterfeit Goods', description: 'Item appears to be fake or counterfeit' },
} as const;
// Expiration Options (in days)
export const EXPIRATION_OPTIONS = [
{ value: 7, label: '7 days' },
{ value: 14, label: '14 days' },
{ value: 30, label: '30 days' },
];
// Currency Options (ISO 4217 codes + Bitcoin)
export const CURRENCY_OPTIONS = [
// Crypto
{ code: 'sats', label: 'sats', symbol: '₿', region: 'Bitcoin' },
{ code: 'BTC', label: 'BTC', symbol: '₿', region: 'Bitcoin' },
// Major Fiat - Americas
{ code: 'USD', label: 'USD', symbol: '$', region: 'United States' },
{ code: 'CAD', label: 'CAD', symbol: 'C$', region: 'Canada' },
{ code: 'MXN', label: 'MXN', symbol: '$', region: 'Mexico' },
{ code: 'BRL', label: 'BRL', symbol: 'R$', region: 'Brazil' },
{ code: 'ARS', label: 'ARS', symbol: '$', region: 'Argentina' },
{ code: 'CLP', label: 'CLP', symbol: '$', region: 'Chile' },
{ code: 'COP', label: 'COP', symbol: '$', region: 'Colombia' },
{ code: 'PEN', label: 'PEN', symbol: 'S/', region: 'Peru' },
// Major Fiat - Europe
{ code: 'EUR', label: 'EUR', symbol: '€', region: 'Eurozone' },
{ code: 'GBP', label: 'GBP', symbol: '£', region: 'United Kingdom' },
{ code: 'CHF', label: 'CHF', symbol: 'Fr', region: 'Switzerland' },
{ code: 'SEK', label: 'SEK', symbol: 'kr', region: 'Sweden' },
{ code: 'NOK', label: 'NOK', symbol: 'kr', region: 'Norway' },
{ code: 'DKK', label: 'DKK', symbol: 'kr', region: 'Denmark' },
{ code: 'PLN', label: 'PLN', symbol: 'zł', region: 'Poland' },
{ code: 'CZK', label: 'CZK', symbol: 'Kč', region: 'Czech Republic' },
{ code: 'HUF', label: 'HUF', symbol: 'Ft', region: 'Hungary' },
{ code: 'RON', label: 'RON', symbol: 'lei', region: 'Romania' },
// Major Fiat - Asia Pacific
{ code: 'JPY', label: 'JPY', symbol: '¥', region: 'Japan' },
{ code: 'CNY', label: 'CNY', symbol: '¥', region: 'China' },
{ code: 'KRW', label: 'KRW', symbol: '₩', region: 'South Korea' },
{ code: 'AUD', label: 'AUD', symbol: 'A$', region: 'Australia' },
{ code: 'NZD', label: 'NZD', symbol: 'NZ$', region: 'New Zealand' },
{ code: 'SGD', label: 'SGD', symbol: 'S$', region: 'Singapore' },
{ code: 'HKD', label: 'HKD', symbol: 'HK$', region: 'Hong Kong' },
{ code: 'TWD', label: 'TWD', symbol: 'NT$', region: 'Taiwan' },
{ code: 'THB', label: 'THB', symbol: '฿', region: 'Thailand' },
{ code: 'PHP', label: 'PHP', symbol: '₱', region: 'Philippines' },
{ code: 'IDR', label: 'IDR', symbol: 'Rp', region: 'Indonesia' },
{ code: 'MYR', label: 'MYR', symbol: 'RM', region: 'Malaysia' },
{ code: 'INR', label: 'INR', symbol: '₹', region: 'India' },
// Middle East & Africa
{ code: 'AED', label: 'AED', symbol: 'د.إ', region: 'UAE' },
{ code: 'SAR', label: 'SAR', symbol: '﷼', region: 'Saudi Arabia' },
{ code: 'ILS', label: 'ILS', symbol: '₪', region: 'Israel' },
{ code: 'TRY', label: 'TRY', symbol: '₺', region: 'Turkey' },
{ code: 'ZAR', label: 'ZAR', symbol: 'R', region: 'South Africa' },
{ code: 'NGN', label: 'NGN', symbol: '₦', region: 'Nigeria' },
{ code: 'EGP', label: 'EGP', symbol: 'E£', region: 'Egypt' },
];
// Helper to get currency symbol from code
export function getCurrencySymbol(code: string): string {
const currency = CURRENCY_OPTIONS.find(c => c.code === code);
return currency?.symbol || code;
}
// Helper to format price with correct currency symbol
export function formatPrice(
price: { amount: string; currency: string } | undefined,
priceType?: string,
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'),
};
}