/** * Rider Card / Park Access Card — shared constants + helpers. * * The personal Rider License used to be client-side only (see NIP.md → Rider * Cards). It is now published as an **addressable** event so a skater's card * customization persists across devices and can be linked to and verified. * * Kinds: * 32271 — Rider Card (addressable, d = "rider-card"). The skater signs it. * 35679 — Park Access Card (addressable). A venue/owner signs it for a rider. * 1293 — Skill badge (regular). A witness signs it for a rider. */ import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; export const RIDER_CARD_KIND = 32271; export const RIDER_CARD_D = 'rider-card'; export const PARK_ACCESS_CARD_KIND = 35679; export const SKILL_BADGE_KIND = 1293; /** Public site origin used for QR / verify links (the app is a HashRouter). */ export const CARD_ORIGIN = 'https://skate-spots.org'; export interface RiderCardConfig { theme: string; sponsors: string; ridesFor: string; showCrews: boolean; showLocal: boolean; showRoles: boolean; /** Custom accent hex that overrides the theme's primary accent ('' = theme). */ accent: string; /** Optional personal logo / crest URL. */ logo: string; /** Whether the printable card has a back side. */ back: boolean; /** Holographic sheen. */ holographic: boolean; } export const DEFAULT_RIDER_CARD: RiderCardConfig = { theme: 'thug', sponsors: '', ridesFor: '', showCrews: true, showLocal: true, showRoles: true, accent: '', logo: '', back: true, holographic: true, }; /** Read a saved Rider Card event back into editable state. */ export function parseRiderCard(event: NostrEvent): RiderCardConfig { const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1] ?? ''; const v2 = (Number(tag('v')) || 0) >= 2; const show = new Set(event.tags.filter(([n]) => n === 'show').map(([, v]) => v)); return { theme: tag('theme') || DEFAULT_RIDER_CARD.theme, sponsors: tag('sponsor'), ridesFor: tag('rides-for'), // Facets arrived in v2; an older card defaults them ON rather than hiding them. showCrews: v2 ? show.has('crews') : true, showLocal: v2 ? show.has('local') : true, showRoles: v2 ? show.has('roles') : true, accent: tag('accent'), logo: tag('logo'), // Back side defaults ON; an explicit ['back','0'] turns it off. back: !(event.tags.some(([n, v]) => n === 'back' && v === '0')), holographic: !(event.tags.some(([n, v]) => n === 'holo' && v === '0')), }; } /** Build the tags for the addressable Rider Card event. */ export function riderCardTags(c: RiderCardConfig): string[][] { const tags: string[][] = [ ['d', RIDER_CARD_D], ['t', 'rider-card'], ['alt', 'SkateSpots Rider Card'], ['theme', c.theme], ['v', '3'], ]; if (c.sponsors.trim()) tags.push(['sponsor', c.sponsors.trim()]); if (c.ridesFor.trim()) tags.push(['rides-for', c.ridesFor.trim()]); if (c.accent.trim()) tags.push(['accent', c.accent.trim()]); if (c.logo.trim()) tags.push(['logo', c.logo.trim()]); tags.push(['back', c.back ? '1' : '0']); tags.push(['holo', c.holographic ? '1' : '0']); if (c.showCrews) tags.push(['show', 'crews']); if (c.showLocal) tags.push(['show', 'local']); if (c.showRoles) tags.push(['show', 'roles']); return tags; } /** * Validity of a pass's `valid-thru` (free text; we accept `YYYY-MM-DD` or any * Date-parseable string). Unknown/empty → 'none' (never pretend it's valid). */ export function passExpiry(validThru?: string): 'none' | 'valid' | 'expired' { if (!validThru || !validThru.trim()) return 'none'; const t = Date.parse(validThru.trim()); if (Number.isNaN(t)) return 'none'; return t < Date.now() ? 'expired' : 'valid'; } /** naddr for an addressable card event. */ export function cardNaddr(kind: number, pubkey: string, d: string): string { return nip19.naddrEncode({ kind, pubkey, identifier: d }); } /** Clean URL that opens the verify page for a card (a startup shim maps it to the hash). */ export function cardVerifyUrl(kind: number, pubkey: string, d: string): string { return `${CARD_ORIGIN}/verify/${cardNaddr(kind, pubkey, d)}`; } /** Clean URL for a public card page. */ export function cardPublicUrl(pubkey: string): string { return `${CARD_ORIGIN}/card/${nip19.npubEncode(pubkey)}`; } // ─── View presets (shared by the editor + the public card) ──────────────────── export const THEMES = [ { id: 'thug', label: 'THUG', emoji: '🔴', desc: 'Black + Neon Red' }, { id: 'lime', label: 'LIME', emoji: '🟢', desc: 'Black + Neon Lime' }, { id: 'chrome', label: 'CHROME', emoji: '⚪', desc: 'Steel & Silver' }, { id: 'wood', label: 'WOOD', emoji: '🟤', desc: 'Woodgrain Classic' }, { id: 'midnight', label: 'MIDNIGHT', emoji: '🔵', desc: 'Deep Blue' }, { id: 'sunset', label: 'SUNSET', emoji: '🟠', desc: 'Orange & Pink' }, ] as const; export type ThemeId = typeof THEMES[number]['id']; export const SKILL_OPTIONS = [ { id: 'kickflip', label: 'Kickflip', icon: '🛹' }, { id: 'heelflip', label: 'Heelflip', icon: '👟' }, { id: 'switch', label: 'Switch', icon: '🔄' }, { id: 'nollie', label: 'Nollie', icon: '⬆️' }, { id: 'fakie', label: 'Fakie', icon: '↩️' }, { id: 'grind', label: 'Grinds', icon: '⚡' }, { id: 'slide', label: 'Slides', icon: '➡️' }, { id: 'manual', label: 'Manuals', icon: '🎯' }, { id: 'vert', label: 'Vert', icon: '🏄' }, { id: 'bowl', label: 'Bowl', icon: '🥣' }, { id: 'street', label: 'Street', icon: '🏙️' }, { id: 'park', label: 'Park', icon: '🌿' }, { id: 'film', label: 'Filmer', icon: '🎥' }, { id: 'diy', label: 'DIY Builder', icon: '🔨' }, ] as const; export const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const; export function shortNpub(pubkey: string) { if (!pubkey) return '—'; const npub = nip19.npubEncode(pubkey); return `${npub.slice(0, 12)}…${npub.slice(-6)}`; } export function fullNpub(pubkey: string) { if (!pubkey) return ''; return nip19.npubEncode(pubkey); } export function memberSince(createdAt: number) { return new Date(createdAt * 1000).getFullYear().toString(); } // ─── Lab role credentials (kinds 35001–35014) ───────────────────────────────── // A skater "wears" a role on their card when they have a Lab profile of that // kind. Labels are i18n'd on the card (`cardUi.roles.*`); `label` is the // English fallback. export const RIDER_ROLE_META: { kind: number; role: string; label: string; emoji: string }[] = [ { kind: 35001, role: 'coach', label: 'Coach', emoji: '🎓' }, { kind: 35002, role: 'filmer', label: 'Filmer', emoji: '🎥' }, { kind: 35003, role: 'editor', label: 'Editor', emoji: '✂️' }, { kind: 35004, role: 'photographer', label: 'Photographer', emoji: '📸' }, { kind: 35005, role: 'builder', label: 'Builder', emoji: '🔨' }, { kind: 35007, role: 'artist', label: 'Artist', emoji: '🎨' }, { kind: 35008, role: 'graffiti', label: 'Graffiti', emoji: '🖌️' }, { kind: 35009, role: 'tattoo', label: 'Tattoo', emoji: '💉' }, { kind: 35010, role: 'trainer', label: 'Trainer', emoji: '💪' }, { kind: 35011, role: 'musician', label: 'Musician', emoji: '🎵' }, { kind: 35012, role: 'judge', label: 'Judge', emoji: '⚖️' }, { kind: 35013, role: 'organizer', label: 'Organizer', emoji: '📣' }, { kind: 35014, role: 'barber', label: 'Barber', emoji: '💈' }, ]; export const RIDER_ROLE_KINDS = RIDER_ROLE_META.map((r) => r.kind); export function roleMeta(role: string) { return RIDER_ROLE_META.find((r) => r.role === role); } /** Short, locale-aware date (+ time range) for the event pass. */ export function formatCardDate(start: number, end?: number): string { const s = new Date(start * 1000); const date = s.toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric', year: 'numeric' }); const time = s.toLocaleTimeString(dateLocaleCode(), { hour: '2-digit', minute: '2-digit' }); if (end && end > start) { const e = new Date(end * 1000); return `${date} · ${time}–${e.toLocaleTimeString(dateLocaleCode(), { hour: '2-digit', minute: '2-digit' })}`; } return `${date} · ${time}`; }