/** * PublicCardPage — /card/:npub * * A read-only view of a skater's Rider Card, shareable and scannable. The QR on * the card opens /verify so anyone can confirm the card is genuine. */ import { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { useSeoMeta } from '@unhead/react'; import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react'; import { useRiderCard, useRiderBadges, useRiderRoles, useCardProfile } from '@/hooks/useRiderCard'; import { useUserCrews } from '@/hooks/useCrews'; import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useTranslation } from '@/contexts/language'; import { genUserName } from '@/lib/genUserName'; import { Button } from '@/components/ui/button'; import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard'; import { RIDER_CARD_KIND, RIDER_CARD_D, DEFAULT_RIDER_CARD, cardVerifyUrl } from '@/lib/riderCard'; import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types'; function decodePubkey(value: string): string | null { if (/^[0-9a-f]{64}$/i.test(value)) return value.toLowerCase(); try { const d = nip19.decode(value); return d.type === 'npub' ? d.data : null; } catch { return null; } } export default function PublicCardPage() { const { t } = useTranslation(); const { npub = '' } = useParams<{ npub: string }>(); const pubkey = decodePubkey(npub); const { user } = useCurrentUser(); const cardProfile = useCardProfile(pubkey ?? undefined); const metadata = cardProfile.data; const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : ''); const { data: savedCard } = useRiderCard(pubkey ?? undefined); const { data: badges = [] } = useRiderBadges(pubkey ?? undefined); const { data: roles = [] } = useRiderRoles(pubkey ?? undefined); const { memberships } = useUserCrews(pubkey ?? undefined); const { data: skaterProfile } = useUserSkaterProfile(pubkey ?? undefined); const derivedStyle = (skaterProfile?.styles ?? []).map((s) => (SKATER_STYLES[s] ? t(SKATER_STYLES[s].labelKey) : s)).join(' · '); const derivedLevel = skaterProfile?.level ? (SKATER_LEVELS[skaterProfile.level] ? t(SKATER_LEVELS[skaterProfile.level].labelKey) : '') : (skaterProfile?.customLevel ?? ''); const [side, setSide] = useState<'front' | 'back'>('front'); useSeoMeta({ title: `${displayName || 'Rider'} card — SkateSpots`, description: `A SkateSpots Rider Card${displayName ? ` for ${displayName}` : ''}.`, }); if (!pubkey) { return (

{t('cardUi.public.invalid')}

); } const config = savedCard?.config ?? DEFAULT_RIDER_CARD; const receivedBadges: BadgeAward[] = badges.map(b => ({ skill: b.skill, witnessName: genUserName(b.witnessPubkey), witnessPubkey: b.witnessPubkey, spotName: b.spotName, timestamp: b.timestamp, })); const cardData: CardData = { type: 'rider', theme: config.theme as CardData['theme'], holographic: config.holographic ?? true, name: displayName, nip05: metadata?.nip05, avatar: metadata?.picture, pubkey, npub: nip19.npubEncode(pubkey), style: derivedStyle, level: derivedLevel || undefined, sponsors: config.sponsors, ridesFor: config.ridesFor, showCrews: config.showCrews, showLocal: config.showLocal, showRoles: config.showRoles, receivedBadges, verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D), accent: config.accent || undefined, logo: config.logo || undefined, side, crews: memberships.map(m => ({ name: m.crew.name, image: m.crew.image })), local: skaterProfile?.location || skaterProfile?.homeSpot || undefined, roles, issuerName: '', recipientName: '', accessLevel: '', validThru: '', }; const isMe = user?.pubkey === pubkey; return (
{isMe ? t('cardUi.public.backToEditor') : t('cardUi.public.backHome')}
{config.back && (
)} {!savedCard?.config && (

{t('cardUi.public.notCustomised')}

)}

{t('cardUi.public.verifyNote')}

); }