/** * RiderCardPage — /card * * Card types: * 1. Rider License — personal card auto-filled from Nostr profile * 2. Park Access Card — issued by a park owner to a rider (signed Nostr event) * * Badge System (kind 1293): * - Anyone can award a skill badge to any rider * - Badges appear on the rider's card with the witness's name * * 6 visual themes: THUG · LIME · CHROME · WOOD · MIDNIGHT · SUNSET * Print-ready: credit-card size (85.6×54mm), @media print isolates card */ import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; // QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API // access at module load time (crashes the preview environment) import { LoginArea } from '@/components/auth/LoginArea'; import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard'; import { NfcWriteButton } from '@/components/NfcWriteButton'; import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas'; import { THEMES, SKILL_OPTIONS, fullNpub, type ThemeId } from '@/lib/riderCard'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useRiderCard, useRiderRoles, useCardProfile, useIssuedPasses, useRevokePass, useReceivedPasses, type ReceivedPass } from '@/hooks/useRiderCard'; import { useUserCrews } from '@/hooks/useCrews'; import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateShops } from '@/hooks/useSkateShops'; import { useSkateEvents } from '@/hooks/useSkateEvents'; import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useNostr } from '@/hooks/useNostr'; import { useQuery } from '@tanstack/react-query'; import { genUserName } from '@/lib/genUserName'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; import { RIDER_CARD_KIND, RIDER_CARD_D, PARK_ACCESS_CARD_KIND, SKILL_BADGE_KIND, cardVerifyUrl, riderCardTags, type RiderCardConfig } from '@/lib/riderCard'; import { SKATE_SPOT_KIND, SKATE_SHOP_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; import { Link, useSearchParams } from 'react-router-dom'; import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket, MoreHorizontal, Pencil, CheckCircle2, RefreshCw, Upload } from 'lucide-react'; // ─── Hook: fetch skill badges received by a pubkey ──────────────────────────── function useReceivedBadges(pubkey: string) { const { nostr } = useNostr(); return useQuery({ queryKey: ['skill-badges', pubkey], queryFn: async (c) => { if (!pubkey) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); const events = await nostr.query( [{ kinds: [SKILL_BADGE_KIND], '#p': [pubkey], limit: 50 }], { signal }, ); return events.filter(e => e.tags.some(([k]) => k === 'p') && e.tags.some(([k]) => k === 'skill') ); }, enabled: !!pubkey, }); } // ─── Hook: fetch author name for a pubkey ───────────────────────────────────── function useWitnessName(pubkey: string) { const author = useAuthor(pubkey); return author.data?.metadata?.name ?? genUserName(pubkey); } // ─── Received badges panel ──────────────────────────────────────────────────── function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) { const { t } = useTranslation(); const { data: badgeEvents = [] } = useReceivedBadges(pubkey); const [expanded, setExpanded] = useState(false); if (badgeEvents.length === 0) { return (
🏅

{t('labUi.rider.noBadges')}

); } const show = expanded ? badgeEvents : badgeEvents.slice(0, 3); return (
{show.map((event) => ( ))} {badgeEvents.length > 3 && ( )}
); } function BadgeEventRow({ event }: { event: NostrEvent }) { const { t } = useTranslation(); const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v); const spot = event.tags.find(([k]) => k === 'spot')?.[1]; const witnessName = useWitnessName(event.pubkey); const witnessAuthor = useAuthor(event.pubkey); const witnessAvatar = witnessAuthor.data?.metadata?.picture; return (
{witnessName.slice(0, 2).toUpperCase()}
{skills.map(sk => { const s = SKILL_OPTIONS.find(o => o.id === sk); return s ? ( {s.icon} {s.label} ) : null; })}

{t('labUi.rider.witnessedBy')} {witnessName} {spot && <> {t('labUi.rider.at')} {spot}}

{new Date(event.created_at * 1000).toLocaleDateString(dateLocaleCode())}
); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function RiderCardPage() { const { t } = useTranslation(); useSeoMeta({ title: t('seoUi.riderCardTitle'), description: t('seoUi.riderCardDesc'), }); const { user } = useCurrentUser(); const cardProfile = useCardProfile(user?.pubkey); const metadata = cardProfile.data; const { data: savedCard } = useRiderCard(user?.pubkey); const { data: allSpots } = useSkateSpots(200); const { data: allShops } = useSkateShops(200); const { memberships } = useUserCrews(user?.pubkey); const { data: skaterProfile } = useUserSkaterProfile(user?.pubkey); const { data: roles = [] } = useRiderRoles(user?.pubkey); const { data: issuedPasses = [] } = useIssuedPasses(user?.pubkey); const { revoke: revokePass, isPending: revokingPass } = useRevokePass(); const { data: receivedPasses = [] } = useReceivedPasses(user?.pubkey); const [selectedPass, setSelectedPass] = useState(null); const crewCrests = useMemo( () => memberships.map(m => ({ name: m.crew.name, image: m.crew.image })), [memberships], ); const local = skaterProfile?.location || skaterProfile?.homeSpot || ''; // Style + level are DERIVED from the Skater profile — no manual typing. const derivedStyle = useMemo( () => (skaterProfile?.styles ?? []).map(s => (SKATER_STYLES[s] ? t(SKATER_STYLES[s].labelKey) : s)).join(' · '), [skaterProfile, t], ); const derivedLevel = skaterProfile?.level ? (SKATER_LEVELS[skaterProfile.level] ? t(SKATER_LEVELS[skaterProfile.level].labelKey) : '') : (skaterProfile?.customLevel ?? ''); // "Finish your card" checklist — a card is only as good as what's on it. const cardChecks = [ { done: !!metadata?.picture, label: t('cardUi.studio.checkPhoto'), to: '/settings?section=profile' }, { done: !!metadata?.name, label: t('cardUi.studio.checkName'), to: '/settings?section=profile' }, { done: (skaterProfile?.styles?.length ?? 0) > 0 || !!skaterProfile?.level, label: t('cardUi.studio.checkProfile'), to: '/skaters' }, { done: memberships.length > 0, label: t('cardUi.studio.checkCrew'), to: '/crews' }, ]; const incomplete = cardChecks.some((c) => !c.done); const { mutate: publishEvent } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: uploadingLogo } = useUploadFile(); const { toast } = useToast(); const displayName = metadata?.name ?? genUserName(user?.pubkey ?? ''); const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : ''; const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? ''); const receivedBadges: BadgeAward[] = receivedBadgeEvents.flatMap(event => { const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v); const spot = event.tags.find(([k]) => k === 'spot')?.[1]; return skills.map(skill => ({ skill, witnessName: genUserName(event.pubkey), witnessPubkey: event.pubkey, spotName: spot, timestamp: event.created_at, })); }); // ── Page tab: 'card' | 'badges' | 'award' | 'passes' ───────────────────── const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award' | 'passes'>('card'); // ── Card state ──────────────────────────────────────────────────────────── const [cardType, setCardType] = useState<'rider' | 'park' | 'crew' | 'event'>('rider'); const [theme, setTheme] = useState('thug'); const [holographic, setHolographic] = useState(true); // Rider fields const [sponsors, setSponsors] = useState(''); const [ridesFor, setRidesFor] = useState(''); const [showCrews, setShowCrews] = useState(true); const [showLocal, setShowLocal] = useState(true); const [showRoles, setShowRoles] = useState(true); // Customization depth const [accent, setAccent] = useState(''); const [logo, setLogo] = useState(''); const [back, setBack] = useState(true); const [cardSide, setCardSide] = useState<'front' | 'back'>('front'); // Park fields const [issuerName, setIssuerName] = useState(''); const [recipientName, setRecipientName] = useState(''); const [recipientNpub, setRecipientNpub] = useState(''); const [accessLevel, setAccessLevel] = useState('Full Access'); const [validThru, setValidThru] = useState(''); const [copied, setCopied] = useState(false); const [rendering, setRendering] = useState(false); // Venue binding: a park pass can name one of the issuer's OWN spots so a // verifier can confirm the issuer really owns the venue (see /verify). const [venueId, setVenueId] = useState(''); const [savedParkD, setSavedParkD] = useState(''); const [crewId, setCrewId] = useState(''); const activeCrew = memberships.find(m => m.crew.id === crewId)?.crew ?? memberships[0]?.crew; // Event pass / wristband: events you organize, or that you RSVP'd going to. const [eventId, setEventId] = useState(''); const { data: allEvents } = useSkateEvents(200); const { data: rsvpCoords = [] } = useMyRsvpCoords(user?.pubkey); const myEvents = useMemo(() => { const rsvpSet = new Set(rsvpCoords); return (allEvents ?? []).filter(ev => ev.pubkey === user?.pubkey || rsvpSet.has(eventCoord(ev))); }, [allEvents, user?.pubkey, rsvpCoords]); const activeEvent = myEvents.find(ev => ev.id === eventId) ?? myEvents[0]; const eventRsvp = useEventRsvps(cardType === 'event' && activeEvent ? eventCoord(activeEvent) : undefined); // Which card entities does this account actually control? const crewRoleOnCard: 'owner' | 'admin' | undefined = memberships.some((m) => m.crew.pubkey === user?.pubkey) ? 'owner' : memberships.some((m) => user?.pubkey && m.crew.admins.includes(user.pubkey)) ? 'admin' : undefined; const organisesEvent = myEvents.some((e) => e.pubkey === user?.pubkey); const eventPasses = activeEvent ? issuedPasses.filter((p) => p.coord === `${CALENDAR_EVENT_KIND}:${activeEvent.pubkey}:${activeEvent.identifier}`) : []; // A "venue" you can issue a pass for is any spot OR shop you own. const myVenues = useMemo(() => { const spots = (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey) .map((s) => ({ id: `s:${s.id}`, kind: SKATE_SPOT_KIND, pubkey: s.pubkey, identifier: s.identifier, title: s.title })); const shops = (allShops ?? []).filter((s) => s.pubkey === user?.pubkey) .map((s) => ({ id: `h:${s.id}`, kind: SKATE_SHOP_KIND, pubkey: s.pubkey, identifier: s.identifier, title: s.name })); return [...spots, ...shops]; }, [allSpots, allShops, user?.pubkey]); const venueSpot = myVenues.find((s) => s.id === venueId); // Deep link from a venue page: /card?venue=:: opens the park // pass editor with that venue preselected. const [searchParams] = useSearchParams(); const venueParam = searchParams.get('venue'); useEffect(() => { if (!venueParam) return; const match = myVenues.find((v) => `${v.kind}:${v.pubkey}:${v.identifier}` === venueParam); if (!match) return; setCardType('park'); setVenueId(match.id); }, [venueParam, myVenues]); // Hydrate the editor from the saved Rider Card the first time it loads. const hydratedRef = useRef(false); useEffect(() => { if (hydratedRef.current || !savedCard?.config) return; hydratedRef.current = true; const c = savedCard.config; setTheme(c.theme as ThemeId); setSponsors(c.sponsors); setRidesFor(c.ridesFor); setShowCrews(c.showCrews); setShowLocal(c.showLocal); setShowRoles(c.showRoles); setAccent(c.accent); setLogo(c.logo); setBack(c.back); setHolographic(c.holographic); }, [savedCard]); // Unsaved-changes tracking for the personal card (manual save — an // interactive signer must not be prompted on every keystroke). const isDirty = useMemo(() => { const c = savedCard?.config; if (!c) return true; return ( c.theme !== theme || c.sponsors !== sponsors || c.ridesFor !== ridesFor || c.showCrews !== showCrews || c.showLocal !== showLocal || c.showRoles !== showRoles || c.accent !== accent || c.logo !== logo || c.back !== back || c.holographic !== holographic ); }, [savedCard, theme, sponsors, ridesFor, showCrews, showLocal, showRoles, accent, logo, back, holographic]); const recipientPubkey = (() => { if (!recipientNpub.trim()) return user?.pubkey ?? ''; try { const d = nip19.decode(recipientNpub.trim()); return d.type === 'npub' ? d.data : (user?.pubkey ?? ''); } catch { return user?.pubkey ?? ''; } })(); const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : cardType === 'park' ? recipientPubkey : cardType === 'crew' ? (activeCrew?.pubkey ?? user?.pubkey ?? '') : (activeEvent?.pubkey ?? ''); const cardData: CardData = useMemo( () => ({ type: cardType, theme, holographic, name: cardType === 'rider' ? displayName : recipientName, nip05: metadata?.nip05, avatar: cardType === 'crew' ? activeCrew?.image : cardType === 'event' ? activeEvent?.image : metadata?.picture, pubkey: cardPubkey, npub: cardPubkey ? fullNpub(cardPubkey) : '', style: derivedStyle, level: derivedLevel || undefined, accent: accent || undefined, accent2: accent || undefined, logo: logo || undefined, side: cardSide, sponsors, ridesFor, crews: crewCrests, local: local || undefined, roles, showCrews, showLocal, showRoles, receivedBadges, verifyUrl: cardType === 'rider' ? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : undefined) : cardType === 'park' ? (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined) : cardType === 'crew' ? (activeCrew ? cardVerifyUrl(SKATE_CREW_KIND, activeCrew.pubkey, activeCrew.identifier) : undefined) : (activeEvent ? `${cardVerifyUrl(CALENDAR_EVENT_KIND, activeEvent.pubkey, activeEvent.identifier)}${activeEvent.pubkey !== user?.pubkey && user?.pubkey ? `?h=${nip19.npubEncode(user.pubkey)}` : ''}` : undefined), crew: cardType === 'crew' && activeCrew ? { name: activeCrew.name, image: activeCrew.image, pubkey: activeCrew.pubkey, identifier: activeCrew.identifier, location: activeCrew.location, founded: activeCrew.founded, members: activeCrew.members.length, focus: activeCrew.focus, vibes: activeCrew.vibes, lookingFor: activeCrew.lookingFor, spots: activeCrew.spotRefs.length, } : undefined, event: cardType === 'event' && activeEvent ? { title: activeEvent.title, start: activeEvent.start, end: activeEvent.end, location: activeEvent.location, image: activeEvent.image, pubkey: activeEvent.pubkey, identifier: activeEvent.identifier, going: eventRsvp.goingCount, holder: displayName, organiser: activeEvent.pubkey === user?.pubkey, } : undefined, issuerName, recipientName, accessLevel, validThru, }), [ cardType, theme, holographic, displayName, recipientName, metadata, cardPubkey, derivedStyle, derivedLevel, sponsors, ridesFor, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey, crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount, accent, logo, cardSide, ], ); // A received pass rendered as a card (type 'park'). const passCardData: CardData | null = useMemo(() => (selectedPass ? { type: 'park', theme: theme as CardData['theme'], holographic: true, name: displayName, pubkey: selectedPass.event.pubkey, npub: '', style: '', sponsors: '', ridesFor: '', receivedBadges: [], verifyUrl: cardVerifyUrl(PARK_ACCESS_CARD_KIND, selectedPass.event.pubkey, selectedPass.d), issuerName: selectedPass.issuer, recipientName: displayName, accessLevel: selectedPass.accessLevel ?? '', validThru: selectedPass.validThru ?? '', } : null), [selectedPass, theme, displayName]); const handlePrintPass = useCallback(async () => { if (!passCardData) return; setRendering(true); try { const dataUrl = await drawCardToCanvas({ type: 'park', themeId: passCardData.theme, name: passCardData.name, pubkey: passCardData.pubkey, npub: '', style: '', sponsors: '', ridesFor: '', showCrews: true, showLocal: true, showRoles: true, receivedBadges: [], verifyUrl: passCardData.verifyUrl, holographic: true, issuerName: passCardData.issuerName, recipientName: passCardData.recipientName, accessLevel: passCardData.accessLevel, validThru: passCardData.validThru, }, 'front'); const w = Math.round(85.6 * 2.835); const h = Math.round(54 * 2.835); const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`); if (!win) { toast({ title: t('labUi.rider.popupBlocked'), variant: 'destructive' }); return; } win.document.write(`Passwindow.onload=function(){window.focus();window.print()}`); win.document.close(); } catch (e) { toast({ title: t('labUi.rider.couldNotRender'), description: String(e), variant: 'destructive' }); } finally { setRendering(false); } }, [passCardData, toast, t]); const renderCardToImage = useCallback(async (side: 'front' | 'back' = 'front'): Promise => { setRendering(true); try { const canvasData: CanvasCardData = { type: cardData.type, themeId: cardData.theme, accentOverride: cardData.accent, name: cardData.name, nip05: cardData.nip05, avatarUrl: cardData.avatar, logoUrl: cardData.logo, pubkey: cardData.pubkey, npub: cardData.npub, style: cardData.style, level: cardData.level, sponsors: cardData.sponsors, ridesFor: cardData.ridesFor, showCrews: cardData.showCrews ?? true, showLocal: cardData.showLocal ?? true, showRoles: cardData.showRoles ?? true, receivedBadges: cardData.receivedBadges, verifyUrl: cardData.verifyUrl, local: cardData.local, crews: (cardData.crews ?? []).map(c => c.name), roles: cardData.roles, crew: cardData.crew, event: cardData.event, issuerName: cardData.issuerName, recipientName: cardData.recipientName, accessLevel: cardData.accessLevel, validThru: cardData.validThru, holographic: cardData.holographic, }; return await drawCardToCanvas(canvasData, side); } catch (err) { console.error('Card render failed:', err); return null; } finally { setRendering(false); } }, [cardData]); const handleSaveCard = useCallback(() => { if (!user) return; const config: RiderCardConfig = { theme, sponsors, ridesFor, showCrews, showLocal, showRoles, accent, logo, back, holographic }; publishEvent( { kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) }, { onSuccess: () => toast({ title: t('cardUi.saved'), description: t('cardUi.savedDesc') }), onError: (e) => toast({ title: t('cardUi.saveFailed'), description: String(e), variant: 'destructive' }), }, ); }, [user, theme, sponsors, ridesFor, showCrews, showLocal, showRoles, accent, logo, back, holographic, publishEvent, toast, t]); const handlePublishEventPass = useCallback(() => { if (!user || !activeEvent) return; if (activeEvent.pubkey !== user.pubkey) { toast({ title: t('cardUi.event.notOrganiser'), variant: 'destructive' }); return; } if (!recipientNpub.trim()) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; } const d = `event-pass-${activeEvent.identifier}-${recipientPubkey.slice(0, 8)}-${Date.now()}`; const tags: string[][] = [ ['d', d], ['p', recipientPubkey], ['t', 'event-checkin'], ['alt', 'Event check-in pass'], ['issuer', activeEvent.title], ['a', `${CALENDAR_EVENT_KIND}:${activeEvent.pubkey}:${activeEvent.identifier}`], ]; publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { onSuccess: () => toast({ title: t('cardUi.event.passIssued') }), onError: (e) => toast({ title: t('cardUi.event.issueFailed'), description: String(e), variant: 'destructive' }), }); }, [user, activeEvent, recipientNpub, recipientPubkey, publishEvent, toast, t]); const handlePrint = useCallback(async () => { const front = await renderCardToImage('front'); if (!front) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; } const backImg = back ? await renderCardToImage('back') : null; const w = Math.round(85.6 * 2.835); const h = Math.round(54 * 2.835); const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`); if (!win) { toast({ title: t('labUi.rider.popupBlocked'), description: t('labUi.rider.popupBlockedDesc'), variant: 'destructive' }); return; } const imgs = `${backImg ? `` : ''}`; win.document.write(`SkateSpots Card${imgs}window.onload=function(){window.focus();window.print()}`); win.document.close(); }, [renderCardToImage, back, toast, t]); const handleDownload = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; } const a = document.createElement('a'); a.href = dataUrl; a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`; a.click(); }, [renderCardToImage, cardData.npub, toast, t]); const handleLogoUpload = useCallback(async (file: File) => { try { const tags = await uploadFile(file); const url = tags[0]?.[1]; if (url) setLogo(url); toast({ title: t('cardUi.logoUploaded') }); } catch (e) { toast({ title: t('cardUi.logoUploadFailed'), description: String(e), variant: 'destructive' }); } }, [uploadFile, toast, t]); const handleCopyNpub = useCallback(() => { navigator.clipboard.writeText(npub); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [npub]); const handleShare = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; } const res = await fetch(dataUrl); const blob = await res.blob(); const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' }); if (navigator.share && navigator.canShare?.({ files: [file] })) { try { await navigator.share({ title: `${cardData.name}'s SkateSpots Card`, text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/#/card/${cardData.npub}`, files: [file] }); return; } catch { /* fall through */ } } await navigator.clipboard.writeText(`https://skate-spots.org/#/card/${cardData.npub}`); toast({ title: t('labUi.rider.linkCopied') }); }, [renderCardToImage, cardData.name, cardData.npub, toast, t]); const handlePublishParkCard = useCallback(() => { if (!user) return; if (!recipientPubkey || !recipientNpub.trim()) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; } // ISSUANCE RULE: only the venue's owner key may issue a Park pass, and it // must be bound to that venue — otherwise the pass proves nothing. if (!venueSpot) { toast({ title: t('cardUi.parkNeedsVenue'), variant: 'destructive' }); return; } const d = `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`; const tags: string[][] = [ ['d', d], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'], ['issuer', issuerName.trim() || venueSpot.title], ['access-level', accessLevel], ['theme', theme], ['a', `${venueSpot.kind}:${venueSpot.pubkey}:${venueSpot.identifier}`], ]; if (validThru) tags.push(['valid-thru', validThru]); publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { onSuccess: () => { setSavedParkD(d); toast({ title: t('labUi.rider.parkPublished') }); }, onError: (e) => toast({ title: t('labUi.rider.publishFailed'), description: String(e), variant: 'destructive' }), }); }, [user, recipientPubkey, recipientNpub, issuerName, accessLevel, validThru, theme, venueSpot, publishEvent, toast, t]); // ── Logged out ──────────────────────────────────────────────────────────── if (!user) { return ( <>
{/* Hero */}
🛹

{t('cardUi.titleNeon')} {t('cardUi.titleRest')}

{t('labUi.rider.heroDesc')}

{t('labUi.rider.heroDesc2')}

); } return ( <>
{/* ── Page header ── */}

{t('cardUi.titleNeon')} {t('cardUi.titleRest')}

{t('labUi.rider.headerTagline')}

{/* ── Tabs: My Card / Badges / Award ── */}
{([ { id: 'card', label: `🪪 ${t('labUi.rider.tabCard')}` }, { id: 'badges', label: `🏅 ${t('labUi.rider.tabBadges')}${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` }, { id: 'award', label: `⭐ ${t('labUi.rider.tabAward')}` }, { id: 'passes', label: `🎟️ ${t('cardUi.passes.tab')}${receivedPasses.length > 0 ? ` (${receivedPasses.length})` : ''}` }, ] as const).map(tab => ( ))}
{pageTab === 'card' && (
{/* ══ LEFT: sticky preview + actions ══ */}
{/* Flip front/back (personal card) */} {cardType === 'rider' && (
)} {/* Saved state */} {cardType === 'rider' && (
{isDirty ? {t('cardUi.studio.unsaved')} : {t('cardUi.studio.saved')}}
)} {/* Primary action: Share + More */}
{back ? t('cardUi.saveFrontPng') : t('labUi.rider.savePng')} {t('labUi.rider.print')} {copied ? : } {copied ? t('common.copied') : t('labUi.rider.copyId')}
{/* Save (personal card only) */} {cardType === 'rider' && ( )}
{/* ══ RIGHT: inspector ══ */}
{/* Finish-your-card checklist (personal card) */} {cardType === 'rider' && incomplete && (
{t('cardUi.studio.finishTitle')}

{t('cardUi.studio.finishDesc')}

{cardChecks.map((c) => ( c.done ? ( ✓ {c.label} ) : ( + {c.label} ) ))}
)} {/* Card type */}

{t('labUi.rider.cardType')}

{([ { id: 'rider' as const, label: t('labUi.rider.licenseLabel'), icon: User, role: undefined }, ...(myVenues.length > 0 ? [{ id: 'park' as const, label: t('labUi.rider.parkLabel'), icon: Building2, role: 'owner' as const }] : []), ...(memberships.length > 0 ? [{ id: 'crew' as const, label: t('cardUi.crew.crewCard'), icon: Users, role: crewRoleOnCard }] : []), ...(myEvents.length > 0 ? [{ id: 'event' as const, label: t('cardUi.event.pass'), icon: Ticket, role: organisesEvent ? ('organiser' as const) : undefined }] : []), ]).map(({ id, label, icon: Icon, role }) => ( ))}
{(myVenues.length === 0 || memberships.length === 0 || myEvents.length === 0) && (

{t('cardUi.studio.moreTypes')}

)}
{/* Theme */}

{t('labUi.rider.theme')}

{THEMES.map((th) => ( ))}
{/* Holographic */}
✨ {t('labUi.rider.holoTitle')}
{t('labUi.rider.holoDesc')}
{/* Design: custom accent + logo (rider only) */} {cardType === 'rider' && ( <>

{t('cardUi.studio.accent')}

setAccent(e.target.value)} className="h-10 w-14 cursor-pointer rounded-lg border border-white/10 bg-transparent" aria-label={t('cardUi.studio.accent')} /> {accent ? : {t('cardUi.studio.accentHint')}}

{t('cardUi.studio.logo')}

{logo && } {logo && }
)} {/* Printable back side */}
{t('cardUi.back.title')}
{t('cardUi.back.desc')}
{/* Show on card — ONE clear list */} {cardType === 'rider' && ( <>

{t('cardUi.studio.showOnCard')}

{[ { icon: '🏕️', label: t('cardUi.facets.crews'), value: String(crewCrests.length), state: showCrews, set: setShowCrews }, { icon: '📍', label: t('cardUi.facets.local'), value: local || '—', state: showLocal, set: setShowLocal }, { icon: '🎓', label: t('cardUi.facets.roles'), value: String(roles.length), state: showRoles, set: setShowRoles }, ].map((row) => (
{row.icon}
{row.label}
{row.value}
))}
{/* Details */}

{t('labUi.rider.cardDetails')}

{t('cardUi.studio.derived')}
{derivedStyle && {derivedStyle}} {derivedLevel && {derivedLevel}} {!derivedStyle && !derivedLevel && ( {t('cardUi.studio.noSkaterProfile')}{' '} {t('cardUi.studio.editSkaterProfile')} )}
{!metadata?.picture && (

{t('cardUi.studio.noPhoto')}{' '} {t('cardUi.studio.addPhoto')}

)}
setRidesFor(e.target.value)} className="h-11 border-white/10 bg-black/50 text-sm" />
setSponsors(e.target.value)} className="h-11 border-white/10 bg-black/50 text-sm" />

⭐ {t('labUi.rider.goldBadgesHint1')}{' '} {t('labUi.rider.goldBadgesHint2')}

)} {/* ── PARK fields ── */} {cardType === 'park' && (
{t('labUi.rider.parkOwnersOnly')}{' '} {t('labUi.rider.parkOwnersDesc')}
setIssuerName(e.target.value)} placeholder={t('labUi.rider.parkNamePlaceholder')} className="h-11 border-white/10 bg-black/50" />
setRecipientName(e.target.value)} placeholder={t('labUi.rider.riderNamePlaceholder')} className="h-11 border-white/10 bg-black/50" />
setRecipientNpub(e.target.value)} placeholder="npub1…" className="h-11 border-white/10 bg-black/50 font-mono text-xs" />
setValidThru(e.target.value)} placeholder="2026-12-31" className="h-11 border-white/10 bg-black/50" />
{myVenues.length > 0 ? ( <>

{t('cardUi.venueHint')}

) : (
{t('cardUi.parkNoVenue')}{' '} {t('cardUi.parkAddVenue')}
)}
{issuedPasses.length > 0 && (

{t('cardUi.parkIssued')}

{issuedPasses.slice(0, 6).map((p) => (
{p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}{p.accessLevel ? ` · ${p.accessLevel}` : ''} {p.status === 'revoked' ? ( {t('cardUi.parkRevoked')} ) : ( )}
))}
)}
)} {/* ── CREW fields ── */} {cardType === 'crew' && (

{t('cardUi.crew.pick')}

{memberships.length > 0 ? ( ) : (

{t('cardUi.crew.none')}

)}

{t('cardUi.crew.hint')}

)} {/* ── EVENT fields (pass / wristband) ── */} {cardType === 'event' && (

{t('cardUi.event.pick')}

{myEvents.length > 0 ? ( ) : (

{t('cardUi.event.none')}

)}

{t('cardUi.event.hint')}

{activeEvent && user && activeEvent.pubkey === user.pubkey && (

{t('cardUi.event.issuePass')}

setRecipientNpub(e.target.value)} placeholder="npub1…" className="h-11 border-white/10 bg-black/50 font-mono text-xs" />

{t('cardUi.event.issuePassHint')}

{eventPasses.length > 0 && (

{t('cardUi.event.issuedPasses')}

{eventPasses.slice(0, 6).map((p) => (
{p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'} {p.status === 'revoked' ? ( {t('cardUi.parkRevoked')} ) : ( )}
))}
)}
)}
)}
)} {/* TAB: MY BADGES */} {pageTab === 'badges' && (

{t('labUi.rider.badgesIntro')}

{t('labUi.rider.earnMore')}

)} {/* TAB: AWARD BADGE */} {pageTab === 'award' && (
{t('labUi.rider.youSawIt')}
{t('labUi.rider.youSawItDesc')}
setPageTab('badges')} />
)} {/* TAB: MY PASSES — access passes issued to me */} {pageTab === 'passes' && (
{receivedPasses.length === 0 ? (

{t('cardUi.passes.none')}

) : ( <> {passCardData && (
)}
{receivedPasses.map((p) => ( ))}
)}
)}
); } // ─── Inline award form (used on the Award tab) ──────────────────────────────── function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) { const { t } = useTranslation(); const { mutate: publishEvent, isPending } = useNostrPublish(); const { toast } = useToast(); const [recipientNpub, setRecipientNpub] = useState(''); const [selectedSkills, setSelectedSkills] = useState([]); const [spotName, setSpotName] = useState(''); const toggleSkill = (id: string) => setSelectedSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]); const handleAward = () => { if (!recipientNpub.trim() || selectedSkills.length === 0) { toast({ title: t('labUi.rider.addRiderSkill'), variant: 'destructive' }); return; } let recipientHex = ''; try { const decoded = nip19.decode(recipientNpub.trim()); if (decoded.type !== 'npub') throw new Error(); recipientHex = decoded.data; } catch { toast({ title: t('labUi.rider.invalidNpub'), variant: 'destructive' }); return; } const tags: string[][] = [ ['p', recipientHex], ['t', 'skill-badge'], ['alt', `Skill badge awarded: ${selectedSkills.join(', ')}`], ...selectedSkills.map(s => ['skill', s]), ]; if (spotName.trim()) tags.push(['spot', spotName.trim()]); publishEvent({ kind: SKILL_BADGE_KIND, content: '', tags }, { onSuccess: () => { toast({ title: t('labUi.rider.badgeAwarded'), description: t('labUi.rider.badgeSent', { skills: selectedSkills.join(', ') }) }); setRecipientNpub(''); setSelectedSkills([]); setSpotName(''); onSuccess(); }, onError: (e) => toast({ title: t('labUi.rider.failed'), description: String(e), variant: 'destructive' }), }); }; return (
setRecipientNpub(e.target.value)} placeholder="npub1…" className="bg-black/50 border-white/10 font-mono text-xs h-11" />
{SKILL_OPTIONS.map(s => ( ))}
setSpotName(e.target.value)} placeholder="MACBA, The Berrics, Burnside…" className="bg-black/50 border-white/10 text-sm h-11" />
); }