feat(card): event ticket vs host + holder RSVP check on /verify

- Attendee card = 'MY TICKET', organiser card = 'EVENT · HOST' — never present a
  self-made card as organiser-issued.
- A ticket's QR now carries ?h=<holder-npub>; /verify fetches that holder's RSVP
  (kind 31925, #a = the event) and shows Ticket holder + RSVP status, so a door
  has a real check with no server.
- NIP.md + AGENTS updated; i18n EN/ES.
This commit is contained in:
SkateSpots 2026-10-02 13:04:11 +07:00
parent 9f3278f135
commit a1952a31e5
7 changed files with 66 additions and 7 deletions

View file

@ -124,6 +124,10 @@ session with **no other memory of this project**. So:
**requires** a venue binding (`a` tag) and is only issuable by that venue's
owner key; `/verify` shows the **holder** + venue match. The studio shows a
**"Finish your card"** checklist (photo/name/styles/crew).
**Event tickets (2026-10-02):** an attendee's card is labelled **MY TICKET**,
an organiser's **EVENT · HOST** (never a self-made "issued" pass); a ticket's
QR carries **`?h=<holder-npub>`** so `/verify` checks the holder's **RSVP**
(kind 31925) and shows Ticket holder + RSVP status.
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page

View file

@ -52,8 +52,7 @@ export interface CardData {
showLocal?: boolean;
showRoles?: boolean;
crew?: CanvasCardData['crew'];
event?: CanvasCardData['event'];
issuerName: string;
event?: CanvasCardData['event']; issuerName: string;
recipientName: string;
accessLevel: string;
validThru: string;

View file

@ -6429,6 +6429,11 @@ walletUi: {
expired: 'Expired',
valid: 'Valid',
holder: 'Holder',
ticketHolder: 'Ticket holder',
rsvp: 'RSVP',
rsvpGoing: 'Going',
rsvpInterested: 'Interested',
rsvpNone: 'No RSVP found',
issuedBy: 'Issued by',
accessLevel: 'Access level',
validThru: 'Valid thru',

View file

@ -6429,6 +6429,11 @@ walletUi: {
expired: 'Caducado',
valid: 'Válido',
holder: 'Titular',
ticketHolder: 'Titular de la entrada',
rsvp: 'Asistencia',
rsvpGoing: 'Va',
rsvpInterested: 'Interesado',
rsvpNone: 'Sin confirmación',
issuedBy: 'Emitido por',
accessLevel: 'Nivel de acceso',
validThru: 'Válido hasta',

View file

@ -62,7 +62,7 @@ export interface CanvasCardData {
};
event?: {
title: string; start: number; end?: number; location?: string; image?: string;
pubkey: string; identifier: string; going?: number; holder?: string;
pubkey: string; identifier: string; going?: number; holder?: string; organiser?: boolean;
};
issuerName: string;
recipientName: string;
@ -304,7 +304,9 @@ async function drawCrewFront(ctx: CanvasRenderingContext2D, data: CanvasCardData
async function drawEventFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const e = data.event;
drawBrand(ctx, p, 'EVENT PASS');
// A ticket is DERIVED from the event + your RSVP; a host badge is for the
// organiser. Never present an attendee's card as organiser-issued.
drawBrand(ctx, p, e?.organiser ? 'EVENT · HOST' : 'MY TICKET');
shadowed(ctx, () => {
ctx.fillStyle = p.text;

View file

@ -354,7 +354,9 @@ export default function RiderCardPage() {
? (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined)
: cardType === 'crew'
? (activeCrew ? cardVerifyUrl(SKATE_CREW_KIND, activeCrew.pubkey, activeCrew.identifier) : undefined)
: (activeEvent ? cardVerifyUrl(CALENDAR_EVENT_KIND, activeEvent.pubkey, activeEvent.identifier) : undefined),
: (activeEvent
? `${cardVerifyUrl(CALENDAR_EVENT_KIND, activeEvent.pubkey, activeEvent.identifier)}${activeEvent.pubkey !== user?.pubkey && user?.pubkey ? `?h=${nip19.npubEncode(user.pubkey)}` : ''}`
: undefined),
crew: cardType === 'crew' && activeCrew ? {
name: activeCrew.name,
image: activeCrew.image,
@ -378,6 +380,7 @@ export default function RiderCardPage() {
identifier: activeEvent.identifier,
going: eventRsvp.goingCount,
holder: displayName,
organiser: activeEvent.pubkey === user?.pubkey,
} : undefined,
issuerName, recipientName, accessLevel, validThru,
}),

View file

@ -7,7 +7,7 @@
*
* It is intentionally honest about what it does and does NOT prove.
*/
import { useParams, Link } from 'react-router-dom';
import { useParams, useSearchParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { nip19, verifyEvent } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
@ -18,7 +18,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { RIDER_CARD_KIND, PARK_ACCESS_CARD_KIND, passExpiry } from '@/lib/riderCard';
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND } from '@/lib/types';
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, CALENDAR_RSVP_KIND } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
interface ParsedNaddr {
@ -42,6 +42,13 @@ function parseNaddr(value: string): ParsedNaddr | null {
export default function VerifyCardPage() {
const { t } = useTranslation();
const { naddr = '' } = useParams<{ naddr: string }>();
const [searchParams] = useSearchParams();
const holderParam = searchParams.get('h') ?? '';
const holderHex = (() => {
if (!holderParam) return '';
if (/^[0-9a-f]{64}$/i.test(holderParam)) return holderParam.toLowerCase();
try { const d = nip19.decode(holderParam); return d.type === 'npub' ? d.data : ''; } catch { return ''; }
})();
const { nostr } = useNostr();
const parsed = parseNaddr(naddr);
@ -88,6 +95,25 @@ export default function VerifyCardPage() {
},
});
// Event ticket: the holder carries ?h=<npub> in the QR, so a door can confirm
// they actually RSVP'd to the organiser's event.
const ticket = useQuery<{ status: string }>({
queryKey: ['ticket-rsvp', naddr, holderHex],
enabled: !!parsed && parsed.kind === CALENDAR_EVENT_KIND && !!holderHex,
queryFn: async (c) => {
if (!parsed || !holderHex) return { status: 'none' };
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const coord = `${parsed.kind}:${parsed.pubkey}:${parsed.identifier}`;
const events = await nostr.query(
[{ kinds: [CALENDAR_RSVP_KIND], authors: [holderHex], '#a': [coord], limit: 5 }],
{ signal },
);
const ev = [...events].sort((a, b) => b.created_at - a.created_at)[0];
if (!ev) return { status: 'none' };
return { status: ev.tags.find(([n]) => n === 'status')?.[1] ?? 'going' };
},
});
const author = useAuthor(parsed?.pubkey ?? '');
const issuerName = author.data?.metadata?.name ?? (parsed ? genUserName(parsed.pubkey) : '');
@ -147,6 +173,21 @@ export default function VerifyCardPage() {
)}
</>
)}
{event.kind === CALENDAR_EVENT_KIND && holderHex && (
<>
<Row label={t('cardUi.verify.ticketHolder')} value={`${nip19.npubEncode(holderHex).slice(0, 14)}…${nip19.npubEncode(holderHex).slice(-6)}`} />
{ticket.data && (
<Row
label={t('cardUi.verify.rsvp')}
value={
ticket.data.status === 'going' ? t('cardUi.verify.rsvpGoing')
: ticket.data.status === 'interested' ? t('cardUi.verify.rsvpInterested')
: t('cardUi.verify.rsvpNone')
}
/>
)}
</>
)}
{event.kind === RIDER_CARD_KIND && (
<Row label={t('cardUi.verify.selfIssued')} value={t('cardUi.verify.selfIssuedDesc')} />
)}