From 892631e9f5b781edc75d158dee76c41823c56e7f Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 13:10:48 +0700 Subject: [PATCH] feat(card): duotone photo, park revocation, organiser check-in passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/hooks/useRiderCard.ts | 67 +++++++++++++++++++++++++++++++- src/i18n/en.ts | 13 +++++++ src/i18n/es.ts | 13 +++++++ src/lib/riderCardCanvas.ts | 14 +++++++ src/pages/RiderCardPage.tsx | 74 +++++++++++++++++++++++++++++++++++- src/pages/VerifyCardPage.tsx | 30 ++++++++++----- 6 files changed, 199 insertions(+), 12 deletions(-) diff --git a/src/hooks/useRiderCard.ts b/src/hooks/useRiderCard.ts index bb6b9bf..619d695 100644 --- a/src/hooks/useRiderCard.ts +++ b/src/hooks/useRiderCard.ts @@ -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({ + 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'] }); + }, + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f09f5a9..d5e4455 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 437c4bf..79cdf19 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/riderCardCanvas.ts b/src/lib/riderCardCanvas.ts index 3055f72..0e1c365 100644 --- a/src/lib/riderCardCanvas.ts +++ b/src/lib/riderCardCanvas.ts @@ -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); diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 8aade6f..304737e 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 } 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() { {t('labUi.rider.publishToNostr')} + + {issuedPasses.length > 0 && ( +
+

{t('cardUi.parkIssued')}

+
+ {issuedPasses.slice(0, 6).map((p) => ( +
+ + {p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}{p.accessLevel ? ` · ${p.accessLevel}` : ''} + + {p.status === 'revoked' ? ( + {t('cardUi.parkRevoked')} + ) : ( + + )} +
+ ))} +
+
+ )} )} @@ -1004,6 +1055,27 @@ export default function RiderCardPage() {

{t('cardUi.event.none')}

)}

{t('cardUi.event.hint')}

+ + {activeEvent && user && activeEvent.pubkey === user.pubkey && ( +
+

{t('cardUi.event.issuePass')}

+ setRecipientNpub(e.target.value)} + placeholder="npub1…" + className="h-11 border-white/10 bg-black/50 font-mono text-xs" + /> + +

{t('cardUi.event.issuePassHint')}

+
+ )} )} diff --git a/src/pages/VerifyCardPage.tsx b/src/pages/VerifyCardPage.tsx index a43a75a..0d95855 100644 --- a/src/pages/VerifyCardPage.tsx +++ b/src/pages/VerifyCardPage.tsx @@ -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 (
@@ -154,7 +162,9 @@ export default function VerifyCardPage() { {event && ( <> - } title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} /> + {revoked + ? } title={t('cardUi.verify.revoked')} body={t('cardUi.verify.revokedDesc')} /> + : } title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />}
@@ -198,7 +208,7 @@ export default function VerifyCardPage() {
-
{t('cardUi.verify.venue')}
+
{venue.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : t('cardUi.verify.venue')}
{venue.status === 'verified' && (
✓ {t('cardUi.verify.venueVerified', { name: venue.title ?? '' })}
)}