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.
This commit is contained in:
SkateSpots 2026-10-02 13:10:48 +07:00
parent aff949bfc0
commit 892631e9f5
6 changed files with 199 additions and 12 deletions

View file

@ -4,9 +4,10 @@
* The Rider Card is an addressable event (kind 32271, d="rider-card") signed by
* the skater themselves, so it persists across devices and can be verified.
*/
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { NostrEvent } from '@nostrify/nostrify';
import {
RIDER_CARD_KIND,
@ -14,6 +15,7 @@ import {
SKILL_BADGE_KIND,
RIDER_ROLE_KINDS,
RIDER_ROLE_META,
PARK_ACCESS_CARD_KIND,
parseRiderCard,
type RiderCardConfig,
} from '@/lib/riderCard';
@ -148,3 +150,66 @@ export function useCardProfile(pubkey: string | undefined) {
},
});
}
export interface IssuedPass {
id: string;
coord: string;
holder?: string;
accessLevel?: string;
issuer?: string;
validThru?: string;
status?: string;
createdAt: number;
event: NostrEvent;
}
/** Access passes this key has ISSUED (kind 35679, authored by them). */
export function useIssuedPasses(pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<IssuedPass[]>({
queryKey: ['issued-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], authors: [pubkey], limit: 100 }],
{ signal },
);
return events
.map((ev) => ({
id: ev.id,
coord: ev.tags.find(([n]) => n === 'a')?.[1] ?? '',
holder: ev.tags.find(([n]) => n === 'p')?.[1],
accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1],
issuer: ev.tags.find(([n]) => n === 'issuer')?.[1],
validThru: ev.tags.find(([n]) => n === 'valid-thru')?.[1],
status: ev.tags.find(([n]) => n === 'status')?.[1],
createdAt: ev.created_at,
event: ev,
}))
.sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/**
* Revoke an issued pass: re-publish it under the SAME `d` with `status=revoked`,
* which supersedes it by addressable replacement (works on every relay, unlike
* a NIP-09 deletion others may ignore).
*/
export function useRevokePass() {
const { mutateAsync: publish, isPending } = useNostrPublish();
const queryClient = useQueryClient();
return {
isPending,
revoke: async (pass: IssuedPass) => {
const tags = pass.event.tags.filter(([n]) => n !== 'status').map((t) => [...t]);
tags.push(['status', 'revoked']);
await publish({ kind: PARK_ACCESS_CARD_KIND, content: '', tags });
queryClient.invalidateQueries({ queryKey: ['issued-passes'] });
},
};
}

View file

@ -6335,6 +6335,10 @@ walletUi: {
parkNoVenue: 'A Park pass can only be issued by the venue’s owner — map a spot you own first.',
parkAddVenue: 'Add a spot',
parkNeedsVenue: 'Pick a venue you own',
parkIssued: 'Issued passes',
parkRevoke: 'Revoke',
parkRevoked: 'Revoked',
parkRevokeFailed: 'Could not revoke',
facets: {
title: 'Show identity facets',
crews: 'Crews',
@ -6379,6 +6383,12 @@ walletUi: {
going: '{{count}} going',
verifiedEvent: 'Verified event',
viewEvent: 'View the event',
issuePass: 'Issue a check-in pass',
issuePassBtn: 'Issue pass',
issuePassHint: 'A signed pass for a specific attendee — they show it at the door.',
notOrganiser: 'Only the organiser can issue passes',
passIssued: 'Pass issued',
issueFailed: 'Could not issue the pass',
},
studio: {
unsaved: 'Unsaved changes',
@ -6426,6 +6436,7 @@ walletUi: {
kindRider: 'Rider Card',
kindCrew: 'Crew Card',
kindEvent: 'Event pass',
organiser: 'Organiser',
expired: 'Expired',
valid: 'Valid',
holder: 'Holder',
@ -6434,6 +6445,8 @@ walletUi: {
rsvpGoing: 'Going',
rsvpInterested: 'Interested',
rsvpNone: 'No RSVP found',
revoked: 'Pass revoked',
revokedDesc: 'The issuer cancelled this pass — do not accept it.',
issuedBy: 'Issued by',
accessLevel: 'Access level',
validThru: 'Valid thru',

View file

@ -6335,6 +6335,10 @@ walletUi: {
parkNoVenue: 'Un pase de parque solo puede emitirlo el dueño de la sede — mapea primero un spot tuyo.',
parkAddVenue: 'Añadir un spot',
parkNeedsVenue: 'Elige una sede tuya',
parkIssued: 'Pases emitidos',
parkRevoke: 'Revocar',
parkRevoked: 'Revocado',
parkRevokeFailed: 'No se pudo revocar',
facets: {
title: 'Mostrar facetas de identidad',
crews: 'Cuadrillas',
@ -6379,6 +6383,12 @@ walletUi: {
going: '{{count}} van',
verifiedEvent: 'Evento verificado',
viewEvent: 'Ver el evento',
issuePass: 'Emitir un pase de acceso',
issuePassBtn: 'Emitir pase',
issuePassHint: 'Un pase firmado para un asistente concreto — lo muestra en la entrada.',
notOrganiser: 'Solo el organizador puede emitir pases',
passIssued: 'Pase emitido',
issueFailed: 'No se pudo emitir el pase',
},
studio: {
unsaved: 'Cambios sin guardar',
@ -6426,6 +6436,7 @@ walletUi: {
kindRider: 'Carné de rider',
kindCrew: 'Carné de cuadrilla',
kindEvent: 'Pase de evento',
organiser: 'Organizador',
expired: 'Caducado',
valid: 'Válido',
holder: 'Titular',
@ -6434,6 +6445,8 @@ walletUi: {
rsvpGoing: 'Va',
rsvpInterested: 'Interesado',
rsvpNone: 'Sin confirmación',
revoked: 'Pase revocado',
revokedDesc: 'El emisor canceló este pase — no lo aceptes.',
issuedBy: 'Emitido por',
accessLevel: 'Nivel de acceso',
validThru: 'Válido hasta',

View file

@ -407,6 +407,20 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
if (bgImg) {
drawCover(ctx, bgImg, CARD_W, CARD_H);
// Branded duotone: desaturate, then tint with the theme accent — so any
// photo reads as a SkateSpots card and text stays legible.
ctx.save();
ctx.globalCompositeOperation = 'saturation';
ctx.globalAlpha = 0.75;
ctx.fillStyle = 'hsl(0, 0%, 50%)';
ctx.fillRect(0, 0, CARD_W, CARD_H);
ctx.restore();
ctx.save();
ctx.globalCompositeOperation = 'color';
ctx.globalAlpha = 0.30;
ctx.fillStyle = p.accent;
ctx.fillRect(0, 0, CARD_W, CARD_H);
ctx.restore();
} else {
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
bg.addColorStop(0, p.bg1);

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 } from '@/hooks/useRiderCard';
import { useRiderCard, useRiderRoles, useCardProfile, useIssuedPasses, useRevokePass } from '@/hooks/useRiderCard';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useSkateSpots } from '@/hooks/useSkateSpots';
@ -172,6 +172,8 @@ export default function RiderCardPage() {
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 })),
@ -450,6 +452,23 @@ export default function RiderCardPage() {
);
}, [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; }
@ -964,6 +983,38 @@ export default function RiderCardPage() {
<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>
)}
@ -1004,6 +1055,27 @@ export default function RiderCardPage() {
<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>

View file

@ -69,22 +69,29 @@ export default function VerifyCardPage() {
if (!event) return { status: 'notfound' as const };
if (!verifyEvent(event)) return { status: 'invalid' as const };
// Park pass — confirm the issuer owns the venue it names, when it names one.
let venue: { status: 'none' | 'verified' | 'mismatch'; title?: string; coord?: string } = { status: 'none' };
// Access pass — confirm the issuer OWNS the target it names (a venue spot
// 30818, or an event 31923 for a check-in pass).
let venue: { status: 'none' | 'verified' | 'mismatch'; title?: string; coord?: string; kind?: number } = { status: 'none' };
if (event.kind === PARK_ACCESS_CARD_KIND) {
const aTag = event.tags.find(([n]) => n === 'a')?.[1];
if (aTag) {
venue = { status: 'mismatch', coord: aTag };
const [k, pk, dTag] = aTag.split(':');
if (k === String(SKATE_SPOT_KIND) && pk && dTag) {
const kindNum = Number(k);
if ((kindNum === SKATE_SPOT_KIND || kindNum === CALENDAR_EVENT_KIND) && pk && dTag) {
try {
const spots = await nostr.query(
[{ kinds: [SKATE_SPOT_KIND], authors: [pk], '#d': [dTag], limit: 1 }],
const targets = await nostr.query(
[{ kinds: [kindNum], authors: [pk], '#d': [dTag], limit: 1 }],
{ signal },
);
const spot = spots[0] as NostrEvent | undefined;
if (spot) {
venue = { status: spot.pubkey === event.pubkey ? 'verified' : 'mismatch', title: spot.tags.find(([n]) => n === 'title')?.[1] || spot.tags.find(([n]) => n === 'name')?.[1], coord: aTag };
const target = targets[0] as NostrEvent | undefined;
if (target) {
venue = {
status: target.pubkey === event.pubkey ? 'verified' : 'mismatch',
title: target.tags.find(([n]) => n === 'title')?.[1] || target.tags.find(([n]) => n === 'name')?.[1],
coord: aTag,
kind: kindNum,
};
}
} catch { /* leave mismatch */ }
}
@ -125,6 +132,7 @@ export default function VerifyCardPage() {
const expiry = passExpiry(validThru);
const holderPubkey = event?.tags.find(([n]) => n === 'p')?.[1];
const holder = holderPubkey ? nip19.npubEncode(holderPubkey) : '';
const revoked = !!event && event.tags.some(([n, v]) => n === 'status' && v === 'revoked');
return (
<div className="max-w-lg mx-auto px-4 py-10">
@ -154,7 +162,9 @@ export default function VerifyCardPage() {
{event && (
<>
<StatusCard tone="good" icon={<BadgeCheck className="h-10 w-10" />} title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />
{revoked
? <StatusCard tone="bad" icon={<ShieldX className="h-10 w-10" />} title={t('cardUi.verify.revoked')} body={t('cardUi.verify.revokedDesc')} />
: <StatusCard tone="good" icon={<BadgeCheck className="h-10 w-10" />} title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />}
<div className="mt-5 rounded-2xl border border-white/10 bg-black/40 divide-y divide-white/5">
<Row label={t('cardUi.verify.kind')} value={event.kind === PARK_ACCESS_CARD_KIND ? t('cardUi.verify.kindPark') : event.kind === SKATE_CREW_KIND ? t('cardUi.verify.kindCrew') : event.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.kindEvent') : t('cardUi.verify.kindRider')} />
@ -198,7 +208,7 @@ export default function VerifyCardPage() {
<div className="mt-4 rounded-2xl border border-white/10 bg-black/40 p-4 flex items-start gap-3">
<MapPin className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<div className="font-bold text-white">{t('cardUi.verify.venue')}</div>
<div className="font-bold text-white">{venue.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : t('cardUi.verify.venue')}</div>
{venue.status === 'verified' && (
<div className="text-green-400 mt-0.5">✓ {t('cardUi.verify.venueVerified', { name: venue.title ?? '' })}</div>
)}