diff --git a/AGENTS.md b/AGENTS.md index c79bd43..ccb6108 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=`** 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 diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx index 1532023..4bd71fa 100644 --- a/src/components/RiderCard.tsx +++ b/src/components/RiderCard.tsx @@ -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; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 8266690..f09f5a9 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 514de0d..437c4bf 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/riderCardCanvas.ts b/src/lib/riderCardCanvas.ts index 5c9be32..3055f72 100644 --- a/src/lib/riderCardCanvas.ts +++ b/src/lib/riderCardCanvas.ts @@ -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; diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 1b93b6f..8aade6f 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -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, }), diff --git a/src/pages/VerifyCardPage.tsx b/src/pages/VerifyCardPage.tsx index c1c8d13..a43a75a 100644 --- a/src/pages/VerifyCardPage.tsx +++ b/src/pages/VerifyCardPage.tsx @@ -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= 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 && ( + <> + + {ticket.data && ( + + )} + + )} {event.kind === RIDER_CARD_KIND && ( )}