feat(card): 'My passes' tab — view & print passes issued to you

A holder previously had no way to see a pass a venue/organiser issued them.
New useReceivedPasses (kind 35679 #p=me) + a fourth /card tab listing them;
selecting one renders it as a card (type park) with a Print button, shows the
access level / valid-thru, and flags revoked passes. Completes the
issue -> receive -> display -> verify loop.
This commit is contained in:
SkateSpots 2026-10-02 13:36:22 +07:00
parent 913d9e60a6
commit a4ef8218e3
4 changed files with 164 additions and 5 deletions

View file

@ -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<ReceivedPass[]>({
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<string, NostrEvent>();
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);
},
});
}

View file

@ -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',

View file

@ -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',

View file

@ -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<ReceivedPass | null>(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(`<!DOCTYPE html><html><head><title>Pass</title><style>@page{size:85.6mm 54mm;margin:0}html,body{margin:0;background:#000}img{display:block;width:85.6mm;height:54mm}</style></head><body><img src="${dataUrl}"/><scr${''}ipt>window.onload=function(){window.focus();window.print()}</scr${''}ipt></body></html>`);
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<string | null> => {
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 => (
<button
key={tab.id}
@ -1126,6 +1171,57 @@ export default function RiderCardPage() {
</div>
)}
{/* TAB: MY PASSES — access passes issued to me */}
{pageTab === 'passes' && (
<div className="mx-auto max-w-2xl space-y-5">
{receivedPasses.length === 0 ? (
<p className="py-10 text-center text-sm text-gray-500">{t('cardUi.passes.none')}</p>
) : (
<>
{passCardData && (
<div className="space-y-4">
<div className="flex justify-center">
<CardPreview d={passCardData} />
</div>
<Button
onClick={handlePrintPass}
disabled={rendering}
className="h-11 w-full gap-2 bg-white/5 font-black uppercase text-gray-200 hover:bg-white/10"
>
<Printer className="h-4 w-4" /> {t('labUi.rider.print')}
</Button>
</div>
)}
<div className="space-y-2">
{receivedPasses.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedPass(p)}
className={cn(
'w-full rounded-xl border p-3 text-left transition-all',
selectedPass?.id === p.id
? 'border-[#ccff00]/50 bg-[#ccff00]/10'
: 'border-white/8 bg-black/30 hover:border-white/20',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-bold text-white">{p.issuer || t('cardUi.passes.pass')}</span>
{p.status === 'revoked' && (
<span className="shrink-0 text-[10px] font-black uppercase text-red-400">{t('cardUi.parkRevoked')}</span>
)}
</div>
<div className="mt-0.5 text-[11px] text-gray-500">
{[p.accessLevel, p.validThru ? `${t('cardUi.verify.validThru')} ${p.validThru}` : ''].filter(Boolean).join(' · ')}
</div>
</button>
))}
</div>
</>
)}
</div>
)}
</div>
</>
);