SkateSpots/src/lib/riderCard.ts
SkateSpots 9357386bbd i18n(es): batch 3 part 2 — locale-aware dates & relative times
- 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.
2026-10-02 15:19:38 +07:00

204 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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}`;
}