289 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|