- New src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow /
formatDistanceToNowStrict / formatDistance / format defaulting to the active
locale, plus dateLocaleCode() for native toLocale{Date,Time}String calls.
- LanguageProvider keeps the locale in sync during render, so the first paint is
already in-language.
- Codemod: 11 date-fns imports now use the shim; ~39 native date/time calls pass
the app locale code instead of undefined/[]/hardcoded 'en-US'. Number
.toLocaleString() calls are deliberately untouched.
204 lines
8.5 KiB
TypeScript
204 lines
8.5 KiB
TypeScript
/**
|
||
* 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}`;
|
||
}
|