/**
* 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(`Pass
window.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.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) => (
))}
{/* 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')}
)}
⭐ {t('labUi.rider.goldBadgesHint1')}{' '}
{t('labUi.rider.goldBadgesHint2')}
>
)}
{/* ── PARK fields ── */}
{cardType === 'park' && (
{t('labUi.rider.parkOwnersOnly')}{' '}
{t('labUi.rider.parkOwnersDesc')}
setRecipientNpub(e.target.value)}
placeholder="npub1…"
className="h-11 border-white/10 bg-black/50 font-mono text-xs"
/>
{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 (
);
}