SkateSpots/src/pages/VerifyCardPage.tsx

289 lines
14 KiB
TypeScript

/**
* VerifyCardPage — /verify/:naddr
*
* VERIFIER-FIRST: a door person scans a QR (or taps an NFC tag) and needs one
* answer in half a second — ✅ / ❌ — then the facts. The technical detail is
* tucked below. No account, no app needed to verify.
*
* It is deliberately honest about what it does and does NOT prove.
*/
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';
import { BadgeCheck, ShieldAlert, ShieldX, Loader2, ExternalLink } from 'lucide-react';
import { useNostr } from '@/hooks/useNostr';
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_SHOP_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, CALENDAR_RSVP_KIND } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
interface ParsedNaddr {
kind: number;
pubkey: string;
identifier: string;
relays: string[];
}
function parseNaddr(value: string): ParsedNaddr | null {
try {
const d = nip19.decode(value);
if (d.type !== 'naddr') return null;
const { kind, pubkey, identifier, relays } = d.data;
return { kind, pubkey, identifier, relays: relays ?? [] };
} catch {
return 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() {
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);
useSeoMeta({ title: 'Verify — SkateSpots', description: 'Verify a SkateSpots card or access pass.' });
const result = useQuery({
queryKey: ['verify-card', naddr],
enabled: !!parsed,
staleTime: 60_000,
queryFn: async (c) => {
if (!parsed) return { status: 'notfound' as const };
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const events = await nostr.query(
[{ kinds: [parsed.kind], authors: [parsed.pubkey], '#d': [parsed.identifier], limit: 1 }],
{ signal },
);
const event = events[0] as NostrEvent | undefined;
if (!event) return { status: 'notfound' as const };
if (!verifyEvent(event)) return { status: 'invalid' as const };
// 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(':');
const kindNum = Number(k);
if ((kindNum === SKATE_SPOT_KIND || kindNum === SKATE_SHOP_KIND || kindNum === CALENDAR_EVENT_KIND) && pk && dTag) {
try {
const targets = await nostr.query(
[{ kinds: [kindNum], authors: [pk], '#d': [dTag], limit: 1 }],
{ signal },
);
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 */ }
}
}
}
return { status: 'valid' as const, event, venue };
},
});
// 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] ?? 'accepted' };
},
});
const author = useAuthor(parsed?.pubkey ?? '');
const issuerName = author.data?.metadata?.name ?? (parsed ? genUserName(parsed.pubkey) : '');
const data = result.data;
const valid = data && data.status === 'valid' ? data : undefined;
const event = valid?.event;
const venue = valid?.venue;
const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1];
const expiry = passExpiry(validThru);
const accessLevel = event?.tags.find(([n]) => n === 'access-level')?.[1];
const holderPubkey = event?.tags.find(([n]) => n === 'p')?.[1];
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) {
// Fail CLOSED: only green when a holder is bound AND their RSVP is positive.
// Missing `?h=`, a loading/errored RSVP, or "none" must not read as admitted.
const rs = ticket.data?.status;
const confirmed = !!holderHex && (rs === 'accepted' || rs === 'going' || rs === 'tentative' || rs === 'interested');
if (confirmed) { tone = 'good'; label = t('cardUi.v.admitted'); sub = t('cardUi.v.admittedDesc'); }
else if (!holderHex) { tone = 'warn'; label = t('cardUi.v.noHolder'); sub = t('cardUi.v.noHolderDesc'); }
else { tone = 'warn'; label = t('cardUi.v.noRsvp'); sub = t('cardUi.v.noRsvpDesc'); }
}
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 (
<div className="mx-auto max-w-lg px-4 py-6">
{/* Verdict hero — the whole point of the page */}
{parsed && result.isLoading ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-gray-400">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="text-xs font-black uppercase tracking-widest">{t('cardUi.verify.checking')}</span>
</div>
) : (
<>
<div className={`rounded-3xl border bg-gradient-to-b to-transparent p-8 text-center ${toneRing}`}>
<Icon className="mx-auto h-16 w-16" />
<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>
{event && (
<>
{/* Facts */}
{facts.filter((f) => f.value).length > 0 && (
<div className="mt-4 grid grid-cols-2 gap-3">
{facts.filter((f) => f.value).map((f) => (
<div key={f.label} className="rounded-xl border border-white/10 bg-black/40 px-3 py-3">
<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>
</div>
))}
</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 === 'accepted' || ticket.data.status === 'going') ? t('cardUi.verify.rsvpGoing') : (ticket.data.status === 'tentative' || 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>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<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-right text-sm font-bold text-white">{value}</span>
</div>
);
}