SkateSpots/src/pages/RiderCardPage.tsx
SkateSpots 892631e9f5 feat(card): duotone photo, park revocation, organiser check-in passes
- Duotone: desaturate + theme-accent tint the background photo so any image
  reads as a branded SkateSpots card (text stays legible). Verified visually.
- Revocation: the venue owner can revoke an issued pass from the studio list;
  revoke re-publishes under the same d with status=revoked (addressable
  supersede — works on every relay). /verify shows a red 'Pass revoked'.
- Organiser check-in: an event's organiser can issue a signed access pass (kind
  35679) bound to the event (a=31923:...); /verify confirms the issuer OWNS the
  event (generalised the venue check to spots OR events) and labels it Organiser.
- useIssuedPasses/useRevokePass; i18n EN/ES.
2026-10-02 13:10:48 +07:00

1220 lines
61 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.

/**
* 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 { 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 { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, 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 { useRiderStats } from '@/hooks/useRiderStats';
import { useRiderCard, useRiderRoles, useCardProfile, useIssuedPasses, useRevokePass } from '@/hooks/useRiderCard';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useSkateSpots } from '@/hooks/useSkateSpots';
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_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { Link } 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 (
<div className="p-3 rounded-xl bg-black/40 border border-gray-700 text-center">
<div className="text-2xl mb-1">🏅</div>
<p className="text-xs text-gray-500">{t('labUi.rider.noBadges')}</p>
</div>
);
}
const show = expanded ? badgeEvents : badgeEvents.slice(0, 3);
return (
<div className="space-y-2">
{show.map((event) => (
<BadgeEventRow key={event.id} event={event} />
))}
{badgeEvents.length > 3 && (
<button
onClick={() => setExpanded(p => !p)}
className="w-full text-xs text-gray-500 hover:text-gray-300 flex items-center justify-center gap-1 py-1"
>
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{expanded ? t('common.showLess') : t('labUi.rider.showMore', { count: badgeEvents.length - 3 })}
</button>
)}
</div>
);
}
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 (
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
<div className="flex-shrink-0">
<Avatar className="h-7 w-7">
<AvatarImage src={witnessAvatar} />
<AvatarFallback className="text-[10px] bg-yellow-900">{witnessName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap gap-1 mb-0.5">
{skills.map(sk => {
const s = SKILL_OPTIONS.find(o => o.id === sk);
return s ? (
<Badge key={sk} variant="secondary" className="text-[9px] px-1.5 py-0 bg-yellow-500/20 text-yellow-300 border-yellow-500/30 h-4">
{s.icon} {s.label}
</Badge>
) : null;
})}
</div>
<p className="text-[10px] text-gray-400">
{t('labUi.rider.witnessedBy')} <span className="text-yellow-400 font-medium">{witnessName}</span>
{spot && <> {t('labUi.rider.at')} <span className="text-gray-300">{spot}</span></>}
</p>
</div>
<div className="text-[9px] text-gray-600 flex-shrink-0">
{new Date(event.created_at * 1000).toLocaleDateString()}
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function RiderCardPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'Skater Card — SkateSpots',
description: 'Create your printable SkateSpots Skater License or Park Access Card.',
});
const { user } = useCurrentUser();
const cardProfile = useCardProfile(user?.pubkey);
const metadata = cardProfile.data;
const { data: stats } = useRiderStats(user?.pubkey);
const { data: savedCard } = useRiderCard(user?.pubkey);
const { data: allSpots } = useSkateSpots(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 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]?.label ?? s).join(' · '),
[skaterProfile],
);
const derivedLevel = skaterProfile?.level
? (SKATER_LEVELS[skaterProfile.level]?.label ?? '')
: (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' ────────────────────────────────
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
// ── Card state ────────────────────────────────────────────────────────────
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew' | 'event'>('rider');
const [theme, setTheme] = useState<ThemeId>('thug');
const [holographic, setHolographic] = useState(true);
// Rider fields
const [sponsors, setSponsors] = useState('');
const [ridesFor, setRidesFor] = useState('');
const [showSpots, setShowSpots] = useState(true);
const [showZaps, setShowZaps] = useState(true);
const [showEvents, setShowEvents] = useState(true);
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);
const myVenues = useMemo(
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey),
[allSpots, user?.pubkey],
);
const venueSpot = myVenues.find((s) => s.id === venueId);
// 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);
setShowSpots(c.showSpots);
setShowZaps(c.showZaps);
setShowEvents(c.showEvents);
setShowCrews(c.showCrews);
setShowLocal(c.showLocal);
setShowRoles(c.showRoles);
setAccent(c.accent);
setLogo(c.logo);
setBack(c.back);
}, [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.showSpots !== showSpots || c.showZaps !== showZaps || c.showEvents !== showEvents ||
c.showCrews !== showCrews || c.showLocal !== showLocal || c.showRoles !== showRoles ||
c.accent !== accent || c.logo !== logo || c.back !== back
);
}, [savedCard, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back]);
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,
skills: [],
accent: accent || undefined,
accent2: accent || undefined,
logo: logo || undefined,
side: cardSide,
sponsors, ridesFor,
showSpots, showZaps, showEvents,
crews: crewCrests,
local: local || undefined,
roles,
showCrews, showLocal, showRoles,
spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0,
eventsCreated: stats?.eventsCreated ?? 0,
memberYear: metadata?.created_at
? memberSince(metadata.created_at as number)
: new Date().getFullYear().toString(),
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, showSpots, showZaps,
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount,
accent, logo, cardSide,
],
);
const renderCardToImage = useCallback(async (side: 'front' | 'back' = 'front'): Promise<string | null> => {
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,
showSpots: cardData.showSpots,
showZaps: cardData.showZaps,
showEvents: cardData.showEvents,
showCrews: cardData.showCrews ?? true,
showLocal: cardData.showLocal ?? true,
showRoles: cardData.showRoles ?? true,
spotsAdded: cardData.spotsAdded,
zapsReceived: cardData.zapsReceived,
eventsCreated: cardData.eventsCreated,
memberYear: cardData.memberYear,
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, style: '', skills: [], sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back };
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, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back, 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 = `<img src="${front}"/>${backImg ? `<img src="${backImg}"/>` : ''}`;
win.document.write(`<!DOCTYPE html><html><head><title>SkateSpots Card</title><style>@page{size:85.6mm 54mm;margin:0}html,body{margin:0;padding:0;background:#000}img{display:block;width:85.6mm;height:54mm;break-after:page}img:last-child{break-after:auto}</style></head><body>${imgs}<scr${''}ipt>window.onload=function(){window.focus();window.print()}</scr${''}ipt></body></html>`);
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/${cardData.npub}`, files: [file] });
return;
} catch { /* fall through */ }
}
await navigator.clipboard.writeText(`https://skate-spots.org/${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', `${SKATE_SPOT_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 (
<>
<div className="min-h-[70vh] flex flex-col items-center justify-center px-4 text-center">
{/* Hero */}
<div className="mb-8 relative">
<div className="absolute inset-0 blur-3xl opacity-20 bg-gradient-to-br from-[#ff0040] to-[#ccff00] rounded-full" />
<div className="relative text-7xl mb-4">🛹</div>
</div>
<h1 className="text-4xl font-black uppercase mb-2">
<span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span>
</h1>
<p className="text-gray-400 max-w-sm mb-2">
{t('labUi.rider.heroDesc')}
</p>
<p className="text-gray-600 text-sm mb-8 max-w-xs">
{t('labUi.rider.heroDesc2')}
</p>
<LoginArea className="justify-center" />
</div>
</>
);
}
return (
<>
<div className="mx-auto max-w-5xl px-4 pb-24">
{/* ── Page header ── */}
<div className="pt-8 pb-6 text-center">
<h1 className="text-3xl font-black uppercase tracking-tight mb-1">
<span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span>
</h1>
<p className="text-gray-500 text-xs tracking-widest uppercase">{t('labUi.rider.headerTagline')}</p>
</div>
{/* ── Tabs: My Card / Badges / Award ── */}
<div className="mx-auto mb-8 flex max-w-2xl overflow-hidden rounded-xl border border-white/10 bg-black/40">
{([
{ 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')}` },
] as const).map(tab => (
<button
key={tab.id}
onClick={() => setPageTab(tab.id)}
className={cn(
'flex-1 py-3 text-xs font-black uppercase tracking-widest transition-all',
pageTab === tab.id ? 'bg-[#ff0040] text-white' : 'text-gray-500 hover:bg-white/5 hover:text-gray-300'
)}
>
{tab.label}
</button>
))}
</div>
{pageTab === 'card' && (
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_400px] lg:items-start lg:gap-8">
{/* ══ LEFT: sticky preview + actions ══ */}
<div className="space-y-4 lg:sticky lg:top-20">
<div
className="relative flex w-full items-center justify-center overflow-hidden rounded-2xl py-8"
style={{ background: 'radial-gradient(ellipse at 50% 60%, rgba(255,0,64,0.08) 0%, rgba(0,0,0,0) 70%), #0a0a0a', border: '1px solid rgba(255,255,255,0.06)' }}
>
<div
className="absolute inset-x-0 top-1/2 h-32 -translate-y-1/2 opacity-30 blur-3xl"
style={{ background: `radial-gradient(ellipse, ${theme === 'lime' ? '#ccff00' : theme === 'midnight' ? '#64a0ff' : theme === 'wood' ? '#f5c842' : theme === 'chrome' ? '#888' : theme === 'sunset' ? '#ff6020' : '#ff0040'}, transparent)` }}
/>
<CardPreview d={cardData} />
</div>
{/* Flip front/back (personal card) */}
{cardType === 'rider' && (
<div className="flex justify-center">
<button
type="button"
onClick={() => setCardSide(s => s === 'front' ? 'back' : 'front')}
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-300 hover:bg-white/10"
>
<RefreshCw className="h-3 w-3" />
{cardSide === 'front' ? t('cardUi.back.showBack') : t('cardUi.back.showFront')}
</button>
</div>
)}
{/* Saved state */}
{cardType === 'rider' && (
<div className="flex items-center justify-center text-[10px] font-black uppercase tracking-widest">
{isDirty
? <span className="inline-flex items-center gap-1.5 text-amber-400"><Pencil className="h-3 w-3" /> {t('cardUi.studio.unsaved')}</span>
: <span className="inline-flex items-center gap-1.5 text-green-400"><CheckCircle2 className="h-3 w-3" /> {t('cardUi.studio.saved')}</span>}
</div>
)}
{/* Primary action: Share + More */}
<div className="flex gap-2">
<Button
onClick={handleShare}
disabled={rendering}
size="lg"
className="h-12 flex-1 gap-2 rounded-xl bg-gradient-to-r from-[#ff0040] to-[#ff6600] font-black uppercase shadow-xl shadow-[#ff0040]/20 hover:opacity-90"
>
{rendering ? <Loader2 className="h-5 w-5 animate-spin" /> : <Share2 className="h-5 w-5" />}
{rendering ? t('labUi.rider.rendering') : t('labUi.rider.shareCard')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="h-12 w-12 rounded-xl border-white/10 px-0">
<MoreHorizontal className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleDownload} disabled={rendering} className="gap-2">
<Download className="h-4 w-4" /> {t('labUi.rider.savePng')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePrint} disabled={rendering} className="gap-2">
<Printer className="h-4 w-4" /> {t('labUi.rider.print')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyNpub} className="gap-2">
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
{copied ? t('common.copied') : t('labUi.rider.copyId')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Save (personal card only) */}
{cardType === 'rider' && (
<Button
onClick={handleSaveCard}
disabled={!isDirty}
className={cn(
'h-12 w-full gap-2 font-black uppercase',
isDirty ? 'bg-[#ccff00] text-black hover:bg-[#b8e600]' : 'bg-white/5 text-gray-500',
)}
>
<Check className="h-4 w-4" />
{isDirty ? t('cardUi.studio.saveChanges') : t('cardUi.studio.allSaved')}
</Button>
)}
</div>
{/* ══ RIGHT: inspector ══ */}
<div className="mt-8 space-y-6 lg:mt-0">
{/* Finish-your-card checklist (personal card) */}
{cardType === 'rider' && incomplete && (
<div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/5 p-4">
<div className="text-xs font-black uppercase tracking-wide text-[#ccff00]">{t('cardUi.studio.finishTitle')}</div>
<p className="mt-1 text-[11px] text-gray-400">{t('cardUi.studio.finishDesc')}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{cardChecks.map((c) => (
c.done ? (
<span key={c.label} className="inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] font-bold text-green-400">
✓ {c.label}
</span>
) : (
<Link key={c.label} to={c.to} className="inline-flex items-center gap-1 rounded-lg border border-[#ccff00]/40 bg-[#ccff00]/10 px-2.5 py-1 text-[11px] font-bold text-[#ccff00] hover:bg-[#ccff00]/20">
+ {c.label}
</Link>
)
))}
</div>
</div>
)}
{/* Card type */}
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('labUi.rider.cardType')}</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{([
{ id: 'rider' as const, label: t('labUi.rider.licenseLabel'), icon: User },
...(myVenues.length > 0 ? [{ id: 'park' as const, label: t('labUi.rider.parkLabel'), icon: Building2 }] : []),
...(memberships.length > 0 ? [{ id: 'crew' as const, label: t('cardUi.crew.crewCard'), icon: Users }] : []),
...(myEvents.length > 0 ? [{ id: 'event' as const, label: t('cardUi.event.pass'), icon: Ticket }] : []),
]).map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setCardType(id)}
className={cn(
'flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 text-center transition-all',
cardType === id
? 'border-[#ff0040]/60 bg-[#ff0040]/10 text-white'
: 'border-white/8 bg-black/30 text-gray-500 hover:border-white/20 hover:text-gray-300'
)}
>
<Icon className={cn('h-4 w-4', cardType === id ? 'text-[#ff0040]' : 'text-gray-600')} />
<span className="text-[9px] font-black uppercase leading-tight">{label}</span>
</button>
))}
</div>
{(myVenues.length === 0 || memberships.length === 0 || myEvents.length === 0) && (
<p className="mt-2 text-[10px] leading-relaxed text-gray-600">{t('cardUi.studio.moreTypes')}</p>
)}
</div>
{/* Theme */}
<div>
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-gray-600">
<Palette className="h-3 w-3" /> {t('labUi.rider.theme')}
</p>
<div className="grid grid-cols-6 gap-2">
{THEMES.map((th) => (
<button
key={th.id}
onClick={() => setTheme(th.id)}
className={cn(
'flex aspect-square flex-col items-center justify-center gap-0.5 rounded-xl border-2 transition-all',
`card-theme-${th.id}`,
theme === th.id ? 'scale-105 border-white/30 ring-2 ring-white/50 ring-offset-1 ring-offset-black' : 'border-white/10 opacity-60 hover:opacity-90'
)}
>
<span className="text-lg">{th.emoji}</span>
<span className="text-[7px] font-black uppercase opacity-80">{th.label}</span>
</button>
))}
</div>
</div>
{/* Holographic */}
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-white/3 px-4 py-3">
<div>
<div className="text-sm font-bold">✨ {t('labUi.rider.holoTitle')}</div>
<div className="text-xs text-gray-500">{t('labUi.rider.holoDesc')}</div>
</div>
<Switch checked={holographic} onCheckedChange={setHolographic} />
</div>
{/* Design: custom accent + logo (rider only) */}
{cardType === 'rider' && (
<>
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.accent')}</p>
<div className="flex items-center gap-3">
<input
type="color"
value={accent || '#ff0040'}
onChange={(e) => 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
? <button type="button" onClick={() => setAccent('')} className="text-xs text-gray-400 underline">{t('cardUi.studio.accentClear')}</button>
: <span className="text-[11px] text-gray-600">{t('cardUi.studio.accentHint')}</span>}
</div>
</div>
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.logo')}</p>
<div className="flex items-center gap-3">
{logo && <img src={logo} alt="" className="h-11 w-11 rounded-lg border border-white/10 object-cover" />}
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs font-bold text-gray-300 hover:bg-white/5">
{uploadingLogo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{logo ? t('cardUi.studio.logoChange') : t('cardUi.studio.logoAdd')}
<input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleLogoUpload(f); }} />
</label>
{logo && <button type="button" onClick={() => setLogo('')} className="text-xs text-gray-400 underline">{t('cardUi.studio.logoClear')}</button>}
</div>
</div>
</>
)}
{/* Printable back side */}
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-white/3 px-4 py-3">
<div>
<div className="text-sm font-bold">{t('cardUi.back.title')}</div>
<div className="text-xs text-gray-500">{t('cardUi.back.desc')}</div>
</div>
<Switch checked={back} onCheckedChange={setBack} />
</div>
{/* Show on card — ONE clear list */}
{cardType === 'rider' && (
<>
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.showOnCard')}</p>
<div className="divide-y divide-white/5 rounded-xl border border-white/8">
{[
{ icon: '🛹', label: t('labUi.pyramid.spots'), value: String(stats?.spotsAdded ?? 0), state: showSpots, set: setShowSpots },
{ icon: '⚡', label: t('labUi.rider.zaps'), value: String(stats?.zapsReceived ?? 0), state: showZaps, set: setShowZaps },
{ icon: '📅', label: t('labUi.pyramid.events'), value: String(stats?.eventsCreated ?? 0), state: showEvents, set: setShowEvents },
{ 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) => (
<div key={row.label} className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span className="text-base">{row.icon}</span>
<div className="min-w-0">
<div className="text-xs font-bold text-white">{row.label}</div>
<div className="truncate text-[10px] text-gray-500">{row.value}</div>
</div>
</div>
<Switch checked={row.state} onCheckedChange={row.set} />
</div>
))}
</div>
</div>
{/* Details */}
<div className="space-y-4 border-t border-white/8 pt-4">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">{t('labUi.rider.cardDetails')}</p>
<div className="rounded-xl border border-white/8 bg-white/3 p-3">
<div className="text-[10px] uppercase tracking-widest text-gray-500">{t('cardUi.studio.derived')}</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{derivedStyle && <span className="rounded-md bg-white/5 px-2 py-0.5 text-[11px] font-bold text-white">{derivedStyle}</span>}
{derivedLevel && <span className="rounded-md px-2 py-0.5 text-[11px] font-bold" style={{ background: 'rgba(255,102,0,0.15)', color: '#ff9944' }}>{derivedLevel}</span>}
{!derivedStyle && !derivedLevel && (
<span className="text-[11px] text-gray-500">
{t('cardUi.studio.noSkaterProfile')}{' '}
<Link to="/skaters" className="text-[#ccff00] underline">{t('cardUi.studio.editSkaterProfile')}</Link>
</span>
)}
</div>
</div>
{!metadata?.picture && (
<p className="text-[11px] text-amber-400">
{t('cardUi.studio.noPhoto')}{' '}
<Link to="/settings?section=profile" className="underline">{t('cardUi.studio.addPhoto')}</Link>
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.ridesFor')}</Label>
<Input
placeholder={t('labUi.rider.ridesForPlaceholder')}
value={ridesFor}
onChange={e => setRidesFor(e.target.value)}
className="h-11 border-white/10 bg-black/50 text-sm"
/>
</div>
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.sponsors')}</Label>
<Input
placeholder={t('labUi.rider.sponsorsPlaceholder')}
value={sponsors}
onChange={e => setSponsors(e.target.value)}
className="h-11 border-white/10 bg-black/50 text-sm"
/>
</div>
</div>
<p className="text-[10px] text-gray-600">
⭐ {t('labUi.rider.goldBadgesHint1')}{' '}
<button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">{t('labUi.rider.tabBadges')}</button>
{t('labUi.rider.goldBadgesHint2')}
</p>
</div>
</>
)}
{/* ── PARK fields ── */}
{cardType === 'park' && (
<div className="space-y-5 border-t border-white/8 pt-4">
<div className="flex items-start gap-3 rounded-xl border border-blue-500/20 bg-blue-500/5 p-4 text-xs text-gray-400">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-400" />
<div>
<span className="font-bold text-blue-300">{t('labUi.rider.parkOwnersOnly')}</span>{' '}
{t('labUi.rider.parkOwnersDesc')}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.parkName')}</Label>
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
placeholder={t('labUi.rider.parkNamePlaceholder')} className="h-11 border-white/10 bg-black/50" />
</div>
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.riderName')}</Label>
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
placeholder={t('labUi.rider.riderNamePlaceholder')} className="h-11 border-white/10 bg-black/50" />
</div>
</div>
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.riderNpub')}</Label>
<Input
value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)}
placeholder="npub1…"
className="h-11 border-white/10 bg-black/50 font-mono text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.accessLevel')}</Label>
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue /></SelectTrigger>
<SelectContent>
{['Full Access','Supervised','Beginner Zone','Street Only','Bowl Only','Members Only'].map(l =>
<SelectItem key={l} value={l}>{l}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.validThru')}</Label>
<Input type="date" value={validThru} onChange={e => setValidThru(e.target.value)}
placeholder="2026-12-31" className="h-11 border-white/10 bg-black/50" />
</div>
</div>
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('cardUi.venue')} *</Label>
{myVenues.length > 0 ? (
<>
<Select value={venueId} onValueChange={setVenueId}>
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue placeholder={t('cardUi.venuePick')} /></SelectTrigger>
<SelectContent>
{myVenues.map(s => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
</SelectContent>
</Select>
<p className="mt-1.5 text-[10px] text-gray-600">{t('cardUi.venueHint')}</p>
</>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-[11px] text-amber-400">
{t('cardUi.parkNoVenue')}{' '}
<Link to="/create-spot" className="underline">{t('cardUi.parkAddVenue')}</Link>
</div>
)}
</div>
<Button
onClick={handlePublishParkCard}
disabled={!venueSpot || !recipientNpub.trim()}
className="h-12 w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#b8e600] disabled:opacity-40"
>
<Zap className="h-4 w-4" />
{t('labUi.rider.publishToNostr')}
</Button>
{issuedPasses.length > 0 && (
<div className="border-t border-white/8 pt-4">
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.parkIssued')}</p>
<div className="space-y-2">
{issuedPasses.slice(0, 6).map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-[11px]">
<span className="truncate text-gray-400">
{p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}{p.accessLevel ? ` · ${p.accessLevel}` : ''}
</span>
{p.status === 'revoked' ? (
<span className="shrink-0 font-bold uppercase text-red-400">{t('cardUi.parkRevoked')}</span>
) : (
<Button
size="sm"
variant="outline"
className="h-7 shrink-0 border-white/10 text-[10px]"
disabled={revokingPass}
onClick={() => {
void revokePass(p)
.then(() => toast({ title: t('cardUi.parkRevoked') }))
.catch(() => toast({ title: t('cardUi.parkRevokeFailed'), variant: 'destructive' }));
}}
>
{t('cardUi.parkRevoke')}
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* ── CREW fields ── */}
{cardType === 'crew' && (
<div className="space-y-5 border-t border-white/8 pt-4">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.crew.pick')}</p>
{memberships.length > 0 ? (
<Select value={activeCrew?.id ?? ''} onValueChange={setCrewId}>
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue /></SelectTrigger>
<SelectContent>
{memberships.map(m => (
<SelectItem key={m.crew.id} value={m.crew.id}>{m.crew.name} · {m.role}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-gray-500">{t('cardUi.crew.none')}</p>
)}
<p className="text-[10px] text-gray-600">{t('cardUi.crew.hint')}</p>
</div>
)}
{/* ── EVENT fields (pass / wristband) ── */}
{cardType === 'event' && (
<div className="space-y-5 border-t border-white/8 pt-4">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.event.pick')}</p>
{myEvents.length > 0 ? (
<Select value={activeEvent?.id ?? ''} onValueChange={setEventId}>
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue /></SelectTrigger>
<SelectContent>
{myEvents.map(ev => (
<SelectItem key={ev.id} value={ev.id}>{ev.title}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-gray-500">{t('cardUi.event.none')}</p>
)}
<p className="text-[10px] text-gray-600">{t('cardUi.event.hint')}</p>
{activeEvent && user && activeEvent.pubkey === user.pubkey && (
<div className="space-y-3 border-t border-white/8 pt-4">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.event.issuePass')}</p>
<Input
value={recipientNpub}
onChange={(e) => setRecipientNpub(e.target.value)}
placeholder="npub1…"
className="h-11 border-white/10 bg-black/50 font-mono text-xs"
/>
<Button
onClick={handlePublishEventPass}
disabled={!recipientNpub.trim()}
className="h-11 w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#b8e600] disabled:opacity-40"
>
<Ticket className="h-4 w-4" />
{t('cardUi.event.issuePassBtn')}
</Button>
<p className="text-[10px] text-gray-600">{t('cardUi.event.issuePassHint')}</p>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* TAB: MY BADGES */}
{pageTab === 'badges' && (
<div className="mx-auto max-w-2xl space-y-6">
<div className="py-2 text-center">
<p className="mx-auto max-w-xs text-sm text-gray-400">{t('labUi.rider.badgesIntro')}</p>
</div>
<ReceivedBadgesPanel pubkey={user.pubkey} />
<div className="border-t border-white/8 pt-2">
<p className="mb-3 text-center text-xs text-gray-600">{t('labUi.rider.earnMore')}</p>
<Button
variant="outline"
onClick={() => setPageTab('award')}
className="w-full gap-2 border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10"
>
<Award className="h-4 w-4" />
{t('labUi.rider.awardSomeone')}
</Button>
</div>
</div>
)}
{/* TAB: AWARD BADGE */}
{pageTab === 'award' && (
<div className="mx-auto max-w-2xl space-y-5">
<div className="flex items-center gap-3 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4">
<Award className="h-5 w-5 flex-shrink-0 text-yellow-400" />
<div>
<div className="text-sm font-bold text-yellow-300">{t('labUi.rider.youSawIt')}</div>
<div className="mt-0.5 text-xs text-gray-400">{t('labUi.rider.youSawItDesc')}</div>
</div>
</div>
<AwardBadgeInline issuerPubkey={user.pubkey} onSuccess={() => setPageTab('badges')} />
</div>
)}
</div>
</>
);
}
// ─── 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<string[]>([]);
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 (
<div className="space-y-5">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderNpub')}</Label>
<Input
value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)}
placeholder="npub1…"
className="bg-black/50 border-white/10 font-mono text-xs h-11"
/>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('labUi.rider.skillsWitnessed')}</Label>
<div className="flex flex-wrap gap-1.5">
{SKILL_OPTIONS.map(s => (
<button
key={s.id}
onClick={() => toggleSkill(s.id)}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-bold border transition-all',
selectedSkills.includes(s.id)
? 'bg-yellow-500/15 border-yellow-400/50 text-yellow-200'
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20'
)}
>
{s.icon} {s.label}
</button>
))}
</div>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.whereSeen')} <span className="normal-case text-gray-600">{t('labUi.rider.optional')}</span></Label>
<Input
value={spotName}
onChange={e => setSpotName(e.target.value)}
placeholder="MACBA, The Berrics, Burnside…"
className="bg-black/50 border-white/10 text-sm h-11"
/>
</div>
<Button
onClick={handleAward}
disabled={isPending || selectedSkills.length === 0 || !recipientNpub.trim()}
className="w-full h-12 bg-yellow-500 hover:bg-yellow-400 text-black font-black uppercase gap-2"
>
<Award className="h-4 w-4" />
{isPending ? t('labUi.rider.signingOnNostr') : t('labUi.rider.awardBadge')}
</Button>
</div>
);
}