/** * RiderCard — the visual card used by both the `/card` editor and the public * `/card/:npub` view. Extracted so there is ONE React rendering of the card * (the canvas export in `RiderCardPage` remains the print renderer). */ import { useEffect, useState } from 'react'; import { SKILL_OPTIONS, shortNpub, roleMeta, type ThemeId } from '@/lib/riderCard'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; export interface BadgeAward { witnessName: string; witnessPubkey: string; skill: string; spotName?: string; timestamp: number; } export interface CardData { type: 'rider' | 'park' | 'crew'; theme: ThemeId; holographic: boolean; // Rider name: string; nip05?: string; avatar?: string; pubkey: string; npub: string; style: string; skills: string[]; sponsors: string; ridesFor: string; showSpots: boolean; showZaps: boolean; showEvents: boolean; spotsAdded: number; zapsReceived: number; eventsCreated: number; memberYear: string; receivedBadges: BadgeAward[]; /** When set, the printed QR opens the verify page for this card. */ verifyUrl?: string; // Identity facets (derived live, toggled on the card) crews?: { name: string; image?: string }[]; local?: string; roles?: string[]; showCrews?: boolean; showLocal?: boolean; showRoles?: boolean; // Crew card (derived from the crew's own signed kind 35006 event) crew?: { name: string; image?: string; pubkey: string; identifier: string; location?: string; founded?: string; members: number; focus?: string; vibes: string[]; lookingFor: string[]; spots: number; }; // Park issuerName: string; recipientName: string; accessLevel: string; validThru: string; } /** Real QR (lazy-imported so canvas API access stays out of module load). */ function CardQr({ url }: { url?: string }) { const [src, setSrc] = useState(null); useEffect(() => { let cancelled = false; if (!url) { setSrc(null); return; } (async () => { try { const QRCode = (await import('qrcode')).default; const dataUrl = await QRCode.toDataURL(url, { width: 88, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' }, }); if (!cancelled) setSrc(dataUrl); } catch { if (!cancelled) setSrc(null); } })(); return () => { cancelled = true; }; }, [url]); return (
{src ? QR :
}
); } // ─── Card visual: Rider front ───────────────────────────────────────────────── // Layout is STRICTLY pixel-fixed to match the 340×214px card container. function RiderFront({ d }: { d: CardData }) { const { t } = useTranslation(); const witnessedBadges = d.receivedBadges.slice(0, 2); return (
{/* Row 1: header */}
🛹 SkateSpots
{t('labUi.rider.previewLicense')}
npub
{shortNpub(d.pubkey)}
{t('labUi.rider.previewSince', { year: d.memberYear })}
{/* Identity facets: local + crew crests */} {((d.showLocal !== false && d.local) || (d.showCrews !== false && (d.crews?.length ?? 0) > 0)) && (
{d.showLocal !== false && d.local && ( 📍 {d.local} )} {d.showCrews !== false && (d.crews ?? []).slice(0, 3).map((c, i) => ( {c.image ? : } {c.name} ))}
)} {/* Row 2: main body */}
{/* Avatar */}
{d.avatar ? ( {d.name} ) : (
🛹
)}
{d.showSpots && (
{t('labUi.pyramid.spots')} {d.spotsAdded}
)} {d.showZaps && (
⚡ {d.zapsReceived}
)} {d.showEvents && (
{t('labUi.rider.previewEvts')} {d.eventsCreated}
)}
{/* Identity block */}
{d.name}
{d.nip05 && (
@{d.nip05.split('@')[0]}
)} {d.style && ( {d.style} )} {d.ridesFor && (
🏪 {d.ridesFor}
)} {d.sponsors && (
🤝 {d.sponsors}
)} {/* Skills — roles, self-claimed, then witnessed (gold) */}
{d.showRoles !== false && (d.roles ?? []).slice(0, 2).map(r => { const m = roleMeta(r); return m ? ( {m.emoji} {t(`cardUi.roles.${m.role}`)} ) : null; })} {d.skills.slice(0, 3).map(sk => { const s = SKILL_OPTIONS.find(o => o.id === sk); return s ? ( {s.icon} {s.label} ) : null; })} {witnessedBadges.map((b, i) => { const s = SKILL_OPTIONS.find(o => o.id === b.skill); return s ? ( ⭐ {s.label} ) : null; })}
{/* QR */}
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
{/* Bottom bar */}
skate-spots.org · Powered by Nostr {shortNpub(d.pubkey)}
); } // ─── Card visual: Park Access front ────────────────────────────────────────── function ParkFront({ d }: { d: CardData }) { const { t } = useTranslation(); return (
🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')}
{t('labUi.rider.previewAccessPass')}
{d.accessLevel || 'Full Access'}
{d.avatar ? ( {d.recipientName} ) : (
🛹
)}
{d.recipientName || t('labUi.rider.previewMemberName')}
{shortNpub(d.pubkey)}
{t('labUi.rider.previewIssuedBy')}
{d.validThru && (
{t('labUi.rider.previewValidThru')} {d.validThru}
)}
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
skate-spots.org · Powered by Nostr
{t('labUi.rider.previewNostrVerified')}
); } // ─── Card visual: Crew front ───────────────────────────────────────────────── function CrewFront({ d }: { d: CardData }) { const { t } = useTranslation(); const c = d.crew; if (!c) return null; return (
{/* Row 1: header */}
🛹 SkateSpots
{t('cardUi.crew.crewCard')}
npub
{shortNpub(c.pubkey)}
{c.founded &&
{t('cardUi.crew.est', { year: c.founded })}
}
{/* Row 2: main body */}
{/* Logo + stats */}
{c.image ? ( {c.name} ) : (
🏕️
)}
{t('cardUi.crew.members')} {c.members}
{t('cardUi.crew.spots')} {c.spots}
{/* Identity block */}
{c.name}
{c.location && (
📍 {c.location}
)} {c.lookingFor.length > 0 && ( {t('cardUi.crew.openCall')} )}
{c.vibes.slice(0, 5).map(v => ( {v} ))}
{/* QR */}
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
{/* Bottom bar */}
skate-spots.org · Powered by Nostr {t('cardUi.crew.verifiedCrew')}
); } export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { const themeClass = `card-theme-${d.theme}`; const holoClass = d.holographic ? 'rider-card-holo' : ''; return (
{d.type === 'rider' && } {d.type === 'park' && } {d.type === 'crew' && }
); }