diff --git a/src/hooks/useRiderCard.ts b/src/hooks/useRiderCard.ts index 619d695..9d28350 100644 --- a/src/hooks/useRiderCard.ts +++ b/src/hooks/useRiderCard.ts @@ -151,8 +151,7 @@ export function useCardProfile(pubkey: string | undefined) { }); } -export interface IssuedPass { - id: string; +export interface IssuedPass { id: string; coord: string; holder?: string; accessLevel?: string; @@ -213,3 +212,57 @@ export function useRevokePass() { }, }; } + +export interface ReceivedPass { + id: string; + d: string; + issuer: string; + accessLevel?: string; + validThru?: string; + status?: string; + targetCoord?: string; + targetKind?: number; + event: NostrEvent; +} + +/** Access passes issued TO this key (kind 35679 with `#p` = them). */ +export function useReceivedPasses(pubkey?: string) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + return useQuery({ + queryKey: ['received-passes', pubkey, getQueryKeySuffix()], + enabled: !!pubkey, + staleTime: 60_000, + queryFn: async () => { + if (!pubkey) return []; + const signal = AbortSignal.timeout(9000); + const events = await querySkateContent( + [{ kinds: [PARK_ACCESS_CARD_KIND], '#p': [pubkey], limit: 200 }], + { signal }, + ); + // Newest version per d-tag (a pass can be superseded by a revocation). + const byD = new Map(); + for (const ev of events) { + const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id; + const cur = byD.get(d); + if (!cur || ev.created_at > cur.created_at) byD.set(d, ev); + } + return [...byD.values()] + .map((ev) => { + const targetCoord = ev.tags.find(([n]) => n === 'a')?.[1]; + return { + id: ev.id, + d: ev.tags.find(([n]) => n === 'd')?.[1] ?? '', + issuer: ev.tags.find(([n]) => n === 'issuer')?.[1] ?? '', + accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1], + validThru: ev.tags.find(([n]) => n === 'valid-thru')?.[1], + status: ev.tags.find(([n]) => n === 'status')?.[1], + targetCoord, + targetKind: targetCoord ? Number(targetCoord.split(':')[0]) : undefined, + event: ev, + }; + }) + .sort((a, b) => b.event.created_at - a.event.created_at); + }, + }); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b74a913..490d693 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6339,6 +6339,11 @@ walletUi: { parkRevoke: 'Revoke', parkRevoked: 'Revoked', parkRevokeFailed: 'Could not revoke', + passes: { + tab: 'My passes', + pass: 'Access pass', + none: 'No passes yet. When a venue or organiser issues you one, it shows up here.', + }, facets: { title: 'Show identity facets', crews: 'Crews', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 404c2de..c99e531 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6339,6 +6339,11 @@ walletUi: { parkRevoke: 'Revocar', parkRevoked: 'Revocado', parkRevokeFailed: 'No se pudo revocar', + passes: { + tab: 'Mis pases', + pass: 'Pase de acceso', + none: 'Aún no tienes pases. Cuando una sede u organizador te emita uno, aparecerá aquí.', + }, facets: { title: 'Mostrar facetas de identidad', crews: 'Cuadrillas', diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 1ba887c..78000c1 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -35,7 +35,7 @@ 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 { 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'; @@ -174,6 +174,8 @@ export default function RiderCardPage() { 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 })), @@ -217,8 +219,8 @@ export default function RiderCardPage() { })); }); - // ── Page tab: 'card' | 'badges' | 'award' ──────────────────────────────── - const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card'); + // ── 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'); @@ -402,6 +404,48 @@ export default function RiderCardPage() { ], ); + // 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: '', skills: [], sponsors: '', ridesFor: '', + showSpots: true, showZaps: true, showEvents: true, + spotsAdded: 0, zapsReceived: 0, eventsCreated: 0, memberYear: '', + 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: '', showSpots: true, showZaps: true, showEvents: true, + showCrews: true, showLocal: true, showRoles: true, + spotsAdded: 0, zapsReceived: 0, eventsCreated: 0, memberYear: '', 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 { @@ -596,6 +640,7 @@ export default function RiderCardPage() { { 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 => ( + + )} +
+ {receivedPasses.map((p) => ( + + ))} +
+ + )} + + )} + );