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:
parent
9f3278f135
commit
a1952a31e5
7 changed files with 66 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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')} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue