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:
parent
913d9e60a6
commit
a4ef8218e3
4 changed files with 164 additions and 5 deletions
|
|
@ -151,8 +151,7 @@ export function useCardProfile(pubkey: string | undefined) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssuedPass {
|
export interface IssuedPass { id: string;
|
||||||
id: string;
|
|
||||||
coord: string;
|
coord: string;
|
||||||
holder?: string;
|
holder?: string;
|
||||||
accessLevel?: 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6339,6 +6339,11 @@ walletUi: {
|
||||||
parkRevoke: 'Revoke',
|
parkRevoke: 'Revoke',
|
||||||
parkRevoked: 'Revoked',
|
parkRevoked: 'Revoked',
|
||||||
parkRevokeFailed: 'Could not revoke',
|
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: {
|
facets: {
|
||||||
title: 'Show identity facets',
|
title: 'Show identity facets',
|
||||||
crews: 'Crews',
|
crews: 'Crews',
|
||||||
|
|
|
||||||
|
|
@ -6339,6 +6339,11 @@ walletUi: {
|
||||||
parkRevoke: 'Revocar',
|
parkRevoke: 'Revocar',
|
||||||
parkRevoked: 'Revocado',
|
parkRevoked: 'Revocado',
|
||||||
parkRevokeFailed: 'No se pudo revocar',
|
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: {
|
facets: {
|
||||||
title: 'Mostrar facetas de identidad',
|
title: 'Mostrar facetas de identidad',
|
||||||
crews: 'Cuadrillas',
|
crews: 'Cuadrillas',
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useRiderStats } from '@/hooks/useRiderStats';
|
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 { useUserCrews } from '@/hooks/useCrews';
|
||||||
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
|
|
@ -174,6 +174,8 @@ export default function RiderCardPage() {
|
||||||
const { data: roles = [] } = useRiderRoles(user?.pubkey);
|
const { data: roles = [] } = useRiderRoles(user?.pubkey);
|
||||||
const { data: issuedPasses = [] } = useIssuedPasses(user?.pubkey);
|
const { data: issuedPasses = [] } = useIssuedPasses(user?.pubkey);
|
||||||
const { revoke: revokePass, isPending: revokingPass } = useRevokePass();
|
const { revoke: revokePass, isPending: revokingPass } = useRevokePass();
|
||||||
|
const { data: receivedPasses = [] } = useReceivedPasses(user?.pubkey);
|
||||||
|
const [selectedPass, setSelectedPass] = useState<ReceivedPass | null>(null);
|
||||||
|
|
||||||
const crewCrests = useMemo(
|
const crewCrests = useMemo(
|
||||||
() => memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
|
() => memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
|
||||||
|
|
@ -217,8 +219,8 @@ export default function RiderCardPage() {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Page tab: 'card' | 'badges' | 'award' ────────────────────────────────
|
// ── Page tab: 'card' | 'badges' | 'award' | 'passes' ─────────────────────
|
||||||
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
|
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award' | 'passes'>('card');
|
||||||
|
|
||||||
// ── Card state ────────────────────────────────────────────────────────────
|
// ── Card state ────────────────────────────────────────────────────────────
|
||||||
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew' | 'event'>('rider');
|
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> => {
|
const renderCardToImage = useCallback(async (side: 'front' | 'back' = 'front'): Promise<string | null> => {
|
||||||
setRendering(true);
|
setRendering(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -596,6 +640,7 @@ export default function RiderCardPage() {
|
||||||
{ id: 'card', label: `🪪 ${t('labUi.rider.tabCard')}` },
|
{ id: 'card', label: `🪪 ${t('labUi.rider.tabCard')}` },
|
||||||
{ id: 'badges', label: `🏅 ${t('labUi.rider.tabBadges')}${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
|
{ id: 'badges', label: `🏅 ${t('labUi.rider.tabBadges')}${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
|
||||||
{ id: 'award', label: `⭐ ${t('labUi.rider.tabAward')}` },
|
{ id: 'award', label: `⭐ ${t('labUi.rider.tabAward')}` },
|
||||||
|
{ id: 'passes', label: `🎟️ ${t('cardUi.passes.tab')}${receivedPasses.length > 0 ? ` (${receivedPasses.length})` : ''}` },
|
||||||
] as const).map(tab => (
|
] as const).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
|
|
@ -1126,6 +1171,57 @@ export default function RiderCardPage() {
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue