feat(verify): verifier-first /verify screen for door staff

Redesigned around the ONE question a verifier needs in half a second:
- Big verdict hero (✅ Valid / ❌ Expired / Revoked / Invalid / ⚠ Unverified
  issuer / Not bound / No RSVP / Admitted / Authentic) with a plain-language line.
- Facts grid (holder, venue/organiser, valid-thru, access level) large and legible.
- Technical detail moved into a collapsed 'Details' section below.
- Combined verdict for access passes (signature + not revoked + not expired +
  issuer owns the venue) and event tickets (organiser's event + holder RSVP).
No account/app needed; NFC tap and QR both land here.
This commit is contained in:
SkateSpots 2026-10-02 14:04:24 +07:00
parent 38ae1273ff
commit 7f9b5d9ccc
3 changed files with 178 additions and 119 deletions

View file

@ -6339,6 +6339,26 @@ walletUi: {
parkRevoke: 'Revoke', parkRevoke: 'Revoke',
parkRevoked: 'Revoked', parkRevoked: 'Revoked',
parkRevokeFailed: 'Could not revoke', parkRevokeFailed: 'Could not revoke',
v: {
valid: 'Valid',
validDesc: 'Signature valid and issued by the venue.',
expired: 'Expired',
expiredDesc: 'Past its valid-thru date.',
revoked: 'Revoked',
revokedDesc: 'The issuer cancelled this pass — do not accept it.',
invalid: 'Invalid',
invalidDesc: 'The signature does not verify. Do not trust it.',
notFound: 'Not found',
notFoundDesc: 'No matching record on the relays.',
unverifiedIssuer: 'Unverified issuer',
unverifiedIssuerDesc: 'This pass names a venue not owned by the issuer’s key.',
notBound: 'Not bound',
notBoundDesc: 'Not bound to a venue — proves nothing about access.',
noRsvp: 'No RSVP',
noRsvpDesc: 'The holder has no RSVP on record for this event.',
admitted: 'Admitted',
admittedDesc: 'Organiser’s event + the holder RSVP’d.',
},
passes: { passes: {
tab: 'My passes', tab: 'My passes',
pass: 'Access pass', pass: 'Access pass',
@ -6464,6 +6484,9 @@ walletUi: {
venueNone: 'Not bound to a venue.', venueNone: 'Not bound to a venue.',
viewCard: 'View the card', viewCard: 'View the card',
disclaimer: 'A valid signature proves who signed this card — it is not proof of skill or of access rights beyond what the venue issued.', disclaimer: 'A valid signature proves who signed this card — it is not proof of skill or of access rights beyond what the venue issued.',
details: 'Details',
invalidLink: 'Invalid link',
invalidLinkDesc: 'That doesn’t look like a valid card link.',
}, },
public: { public: {
invalid: 'That doesn’t look like a valid card link.', invalid: 'That doesn’t look like a valid card link.',

View file

@ -6339,6 +6339,26 @@ walletUi: {
parkRevoke: 'Revocar', parkRevoke: 'Revocar',
parkRevoked: 'Revocado', parkRevoked: 'Revocado',
parkRevokeFailed: 'No se pudo revocar', parkRevokeFailed: 'No se pudo revocar',
v: {
valid: 'Válido',
validDesc: 'Firma válida y emitido por la sede.',
expired: 'Caducado',
expiredDesc: 'Ha pasado su fecha de validez.',
revoked: 'Revocado',
revokedDesc: 'El emisor canceló este pase — no lo aceptes.',
invalid: 'No válido',
invalidDesc: 'La firma no se verifica. No te fíes.',
notFound: 'No encontrado',
notFoundDesc: 'No hay registro en los relés.',
unverifiedIssuer: 'Emisor sin verificar',
unverifiedIssuerDesc: 'Este pase nombra una sede que no pertenece a la clave del emisor.',
notBound: 'Sin vincular',
notBoundDesc: 'Sin vincular a una sede — no prueba nada sobre el acceso.',
noRsvp: 'Sin confirmación',
noRsvpDesc: 'El titular no tiene confirmación registrada para este evento.',
admitted: 'Admitido',
admittedDesc: 'Evento del organizador + el titular confirmó.',
},
passes: { passes: {
tab: 'Mis pases', tab: 'Mis pases',
pass: 'Pase de acceso', pass: 'Pase de acceso',
@ -6464,6 +6484,9 @@ walletUi: {
venueNone: 'Sin vincular a ninguna sede.', venueNone: 'Sin vincular a ninguna sede.',
viewCard: 'Ver el carné', viewCard: 'Ver el carné',
disclaimer: 'Una firma válida demuestra quién firmó este carné — no es prueba de habilidad ni de derechos de acceso más allá de lo que emitió la sede.', disclaimer: 'Una firma válida demuestra quién firmó este carné — no es prueba de habilidad ni de derechos de acceso más allá de lo que emitió la sede.',
details: 'Detalles',
invalidLink: 'Enlace no válido',
invalidLinkDesc: 'Eso no parece un enlace de carné válido.',
}, },
public: { public: {
invalid: 'Eso no parece un enlace de carné válido.', invalid: 'Eso no parece un enlace de carné válido.',

View file

@ -1,17 +1,17 @@
/** /**
* VerifyCardPage — /verify/:naddr * VerifyCardPage — /verify/:naddr
* *
* Anyone can open this (scan the QR on a printed card) to check a Park Access * VERIFIER-FIRST: a door person scans a QR (or taps an NFC tag) and needs one
* Card or Rider Card: it fetches the event, verifies the signature, and — for a * answer in half a second — ✅ / ❌ — then the facts. The technical detail is
* park pass — confirms the issuer really owns the venue it names. * tucked below. No account, no app needed to verify.
* *
* It is intentionally honest about what it does and does NOT prove. * It is deliberately honest about what it does and does NOT prove.
*/ */
import { useParams, useSearchParams, Link } from 'react-router-dom'; import { useParams, useSearchParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { nip19, verifyEvent } from 'nostr-tools'; import { nip19, verifyEvent } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { BadgeCheck, ShieldAlert, ShieldX, Loader2, ExternalLink, MapPin } from 'lucide-react'; import { BadgeCheck, ShieldAlert, ShieldX, Loader2, ExternalLink } from 'lucide-react';
import { useNostr } from '@/hooks/useNostr'; import { useNostr } from '@/hooks/useNostr';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
@ -39,6 +39,13 @@ function parseNaddr(value: string): ParsedNaddr | null {
} }
} }
function shortNpub(hex: string): string {
if (!hex) return '—';
try { const n = nip19.npubEncode(hex); return `${n.slice(0, 12)}…${n.slice(-6)}`; } catch { return hex.slice(0, 12); }
}
type Tone = 'good' | 'warn' | 'bad';
export default function VerifyCardPage() { export default function VerifyCardPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { naddr = '' } = useParams<{ naddr: string }>(); const { naddr = '' } = useParams<{ naddr: string }>();
@ -52,7 +59,7 @@ export default function VerifyCardPage() {
const { nostr } = useNostr(); const { nostr } = useNostr();
const parsed = parseNaddr(naddr); const parsed = parseNaddr(naddr);
useSeoMeta({ title: 'Verify a card — SkateSpots', description: 'Verify a SkateSpots Rider Card or Park Access Card.' }); useSeoMeta({ title: 'Verify — SkateSpots', description: 'Verify a SkateSpots card or access pass.' });
const result = useQuery({ const result = useQuery({
queryKey: ['verify-card', naddr], queryKey: ['verify-card', naddr],
@ -130,140 +137,146 @@ export default function VerifyCardPage() {
const venue = valid?.venue; const venue = valid?.venue;
const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1]; const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1];
const expiry = passExpiry(validThru); const expiry = passExpiry(validThru);
const accessLevel = event?.tags.find(([n]) => n === 'access-level')?.[1];
const holderPubkey = event?.tags.find(([n]) => n === 'p')?.[1]; 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'); const revoked = !!event && event.tags.some(([n, v]) => n === 'status' && v === 'revoked');
const isPark = event?.kind === PARK_ACCESS_CARD_KIND;
const isTicket = event?.kind === CALENDAR_EVENT_KIND;
const isCrew = event?.kind === SKATE_CREW_KIND;
const isRider = event?.kind === RIDER_CARD_KIND;
// ── The single verdict a door person needs at a glance ──────────────────────
let tone: Tone = 'warn';
let label = t('cardUi.verify.notFound');
let sub = t('cardUi.verify.notFoundDesc');
if (!parsed) {
tone = 'bad'; label = t('cardUi.verify.invalidLink'); sub = t('cardUi.verify.invalidLinkDesc');
} else if (data?.status === 'invalid') {
tone = 'bad'; label = t('cardUi.verify.invalid'); sub = t('cardUi.verify.invalidDesc');
} else if (data?.status === 'notfound') {
tone = 'warn'; label = t('cardUi.verify.notFound'); sub = t('cardUi.verify.notFoundDesc');
} else if (event) {
if (isPark && revoked) { tone = 'bad'; label = t('cardUi.v.revoked'); sub = t('cardUi.v.revokedDesc'); }
else if (isPark && expiry === 'expired') { tone = 'bad'; label = t('cardUi.v.expired'); sub = t('cardUi.v.expiredDesc'); }
else if (isPark && venue?.status === 'mismatch') { tone = 'warn'; label = t('cardUi.v.unverifiedIssuer'); sub = t('cardUi.v.unverifiedIssuerDesc'); }
else if (isPark && venue?.status === 'none') { tone = 'warn'; label = t('cardUi.v.notBound'); sub = t('cardUi.v.notBoundDesc'); }
else if (isTicket && holderHex && ticket.data?.status === 'none') { tone = 'warn'; label = t('cardUi.v.noRsvp'); sub = t('cardUi.v.noRsvpDesc'); }
else if (isTicket) { tone = 'good'; label = t('cardUi.v.admitted'); sub = t('cardUi.v.admittedDesc'); }
else if (isPark) { tone = 'good'; label = t('cardUi.v.valid'); sub = t('cardUi.v.validDesc'); }
else { tone = 'good'; label = t('cardUi.verify.authentic'); sub = t('cardUi.verify.authenticDesc'); }
}
const toneRing = tone === 'good' ? 'border-green-500/40 from-green-500/15 text-green-400'
: tone === 'warn' ? 'border-amber-500/40 from-amber-500/15 text-amber-400'
: 'border-red-500/40 from-red-500/15 text-red-400';
const Icon = tone === 'good' ? BadgeCheck : tone === 'warn' ? ShieldAlert : ShieldX;
// Facts a verifier actually reads.
const facts: Array<{ label: string; value: string }> = [];
if (isPark) {
if (holderPubkey) facts.push({ label: t('cardUi.verify.holder'), value: shortNpub(holderPubkey) });
if (venue?.title || issuerName) facts.push({ label: venue?.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : t('cardUi.verify.venue'), value: venue?.title || issuerName });
if (validThru) facts.push({ label: t('cardUi.verify.validThru'), value: validThru });
if (accessLevel) facts.push({ label: t('cardUi.verify.accessLevel'), value: accessLevel });
} else if (isTicket) {
if (holderHex) facts.push({ label: t('cardUi.verify.ticketHolder'), value: shortNpub(holderHex) });
const title = event?.tags.find(([n]) => n === 'title')?.[1];
if (title) facts.push({ label: t('cardUi.verify.kindEvent'), value: title });
} else if (isCrew) {
const name = event?.tags.find(([n]) => n === 'name')?.[1];
if (name) facts.push({ label: t('cardUi.verify.kindCrew'), value: name });
} else if (isRider) {
facts.push({ label: t('cardUi.verify.issuedBy'), value: issuerName });
}
const kindLabel = isPark ? t('cardUi.verify.kindPark') : isCrew ? t('cardUi.verify.kindCrew') : isTicket ? t('cardUi.verify.kindEvent') : t('cardUi.verify.kindRider');
return ( return (
<div className="max-w-lg mx-auto px-4 py-10"> <div className="mx-auto max-w-lg px-4 py-6">
<div className="text-center mb-6"> {/* Verdict hero — the whole point of the page */}
<h1 className="text-3xl font-black uppercase tracking-tight"> {parsed && result.isLoading ? (
<span className="neon-red">VERIFY</span> <span className="text-white">CARD</span> <div className="flex flex-col items-center justify-center gap-3 py-24 text-gray-400">
</h1> <Loader2 className="h-8 w-8 animate-spin" />
</div> <span className="text-xs font-black uppercase tracking-widest">{t('cardUi.verify.checking')}</span>
{!parsed && (
<StatusCard tone="warn" icon={<ShieldAlert className="h-10 w-10" />} title={t('cardUi.verify.notFound')} body={t('cardUi.verify.notFoundDesc')} />
)}
{parsed && result.isLoading && (
<div className="flex items-center justify-center gap-2 py-16 text-gray-400">
<Loader2 className="h-5 w-5 animate-spin" /> {t('cardUi.verify.checking')}
</div> </div>
)} ) : (
{parsed && !result.isLoading && data?.status === 'notfound' && (
<StatusCard tone="warn" icon={<ShieldAlert className="h-10 w-10" />} title={t('cardUi.verify.notFound')} body={t('cardUi.verify.notFoundDesc')} />
)}
{parsed && data?.status === 'invalid' && (
<StatusCard tone="bad" icon={<ShieldX className="h-10 w-10" />} title={t('cardUi.verify.invalid')} body={t('cardUi.verify.invalidDesc')} />
)}
{event && (
<> <>
{revoked <div className={`rounded-3xl border bg-gradient-to-b to-transparent p-8 text-center ${toneRing}`}>
? <StatusCard tone="bad" icon={<ShieldX className="h-10 w-10" />} title={t('cardUi.verify.revoked')} body={t('cardUi.verify.revokedDesc')} /> <Icon className="mx-auto h-16 w-16" />
: <StatusCard tone="good" icon={<BadgeCheck className="h-10 w-10" />} title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />} <div className="mt-3 text-4xl font-black uppercase tracking-tight">{label}</div>
<p className="mx-auto mt-2 max-w-xs text-sm text-gray-300">{sub}</p>
<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')} />
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
{event.kind === PARK_ACCESS_CARD_KIND && (
<>
{holder && (
<Row label={t('cardUi.verify.holder')} value={`${holder.slice(0, 14)}…${holder.slice(-6)}`} />
)}
<Row label={t('cardUi.verify.accessLevel')} value={event.tags.find(([n]) => n === 'access-level')?.[1] ?? '—'} />
{validThru && (
<Row
label={t('cardUi.verify.validThru')}
value={expiry === 'expired' ? `${validThru} · ${t('cardUi.verify.expired')}` : validThru}
/>
)}
</>
)}
{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')} />
)}
</div> </div>
{/* Venue binding (park passes) */} {event && (
{event.kind === PARK_ACCESS_CARD_KIND && venue && ( <>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/40 p-4 flex items-start gap-3"> {/* Facts */}
<MapPin className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" /> {facts.filter((f) => f.value).length > 0 && (
<div className="text-sm"> <div className="mt-4 grid grid-cols-2 gap-3">
<div className="font-bold text-white">{venue.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : t('cardUi.verify.venue')}</div> {facts.filter((f) => f.value).map((f) => (
{venue.status === 'verified' && ( <div key={f.label} className="rounded-xl border border-white/10 bg-black/40 px-3 py-3">
<div className="text-green-400 mt-0.5">✓ {t('cardUi.verify.venueVerified', { name: venue.title ?? '' })}</div> <div className="text-[10px] uppercase tracking-widest text-gray-500">{f.label}</div>
)} <div className="mt-1 break-words text-sm font-bold text-white">{f.value}</div>
{venue.status === 'mismatch' && ( </div>
<div className="text-amber-400 mt-0.5">{t('cardUi.verify.venueMismatch')}</div> ))}
)} </div>
{venue.status === 'none' && ( )}
<div className="text-gray-400 mt-0.5">{t('cardUi.verify.venueNone')}</div>
{/* Technical detail (collapsed) */}
<details className="mt-4 rounded-xl border border-white/10 bg-black/40">
<summary className="cursor-pointer px-4 py-3 text-[11px] font-black uppercase tracking-widest text-gray-500">
{t('cardUi.verify.details')}
</summary>
<div className="divide-y divide-white/5 border-t border-white/5">
<Row label={t('cardUi.verify.kind')} value={kindLabel} />
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
{isPark && venue && (
<Row
label={venue.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : t('cardUi.verify.venue')}
value={venue.status === 'verified' ? t('cardUi.verify.venueVerified', { name: venue.title ?? '' }) : venue.status === 'mismatch' ? t('cardUi.verify.venueMismatch') : t('cardUi.verify.venueNone')}
/>
)}
{isTicket && 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')}
/>
)}
</div>
</details>
<div className="mt-5 flex flex-col gap-2">
{parsed && (
isCrew ? (
<Link to={`/crew/${parsed.pubkey}/${parsed.identifier}`} className="inline-flex items-center justify-center gap-1.5 text-center text-sm text-[#ccff00] hover:underline">
{t('cardUi.crew.viewCrew')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
) : isTicket ? (
<Link to={`/event/${parsed.pubkey}/${parsed.identifier}`} className="inline-flex items-center justify-center gap-1.5 text-center text-sm text-[#ccff00] hover:underline">
{t('cardUi.event.viewEvent')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
) : (
<Link to={`/card/${nip19.npubEncode(parsed.pubkey)}`} className="inline-flex items-center justify-center gap-1.5 text-center text-sm text-[#ccff00] hover:underline">
{t('cardUi.verify.viewCard')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
)
)} )}
<p className="mt-1 text-center text-[11px] text-gray-600">{t('cardUi.verify.disclaimer')}</p>
</div> </div>
</div> </>
)} )}
<div className="mt-6 flex flex-col gap-2">
{parsed && (
parsed.kind === SKATE_CREW_KIND ? (
<Link to={`/crew/${parsed.pubkey}/${parsed.identifier}`} className="text-center text-sm text-[#ccff00] hover:underline inline-flex items-center justify-center gap-1.5">
{t('cardUi.crew.viewCrew')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
) : parsed.kind === CALENDAR_EVENT_KIND ? (
<Link to={`/event/${parsed.pubkey}/${parsed.identifier}`} className="text-center text-sm text-[#ccff00] hover:underline inline-flex items-center justify-center gap-1.5">
{t('cardUi.event.viewEvent')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
) : (
<Link to={`/card/${nip19.npubEncode(parsed.pubkey)}`} className="text-center text-sm text-[#ccff00] hover:underline inline-flex items-center justify-center gap-1.5">
{t('cardUi.verify.viewCard')} <ExternalLink className="h-3.5 w-3.5" />
</Link>
)
)}
<p className="text-[11px] text-gray-600 text-center mt-1">{t('cardUi.verify.disclaimer')}</p>
</div>
</> </>
)} )}
</div> </div>
); );
} }
function StatusCard({ tone, icon, title, body }: { tone: 'good' | 'warn' | 'bad'; icon: React.ReactNode; title: string; body: string }) {
const ring = tone === 'good' ? 'border-green-500/40 bg-green-500/5 text-green-400'
: tone === 'warn' ? 'border-amber-500/40 bg-amber-500/5 text-amber-400'
: 'border-red-500/40 bg-red-500/5 text-red-400';
return (
<div className={`rounded-2xl border ${ring} p-6 text-center`}>
<div className="flex justify-center mb-3">{icon}</div>
<div className="text-lg font-black uppercase tracking-wide">{title}</div>
<p className="text-sm text-gray-300 mt-2">{body}</p>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) { function Row({ label, value }: { label: string; value: string }) {
return ( return (
<div className="flex items-center justify-between gap-4 px-4 py-3"> <div className="flex items-center justify-between gap-4 px-4 py-3">
<span className="text-[11px] uppercase tracking-widest text-gray-500">{label}</span> <span className="text-[11px] uppercase tracking-widest text-gray-500">{label}</span>
<span className="text-sm font-bold text-white text-right">{value}</span> <span className="text-right text-sm font-bold text-white">{value}</span>
</div> </div>
); );
} }