4106 lines
150 KiB
TypeScript
4106 lines
150 KiB
TypeScript
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'),
|
||
};
|
||
}
|