SkateSpots/src/pages/VerifyCardPage.tsx
SkateSpots 9f3278f135 feat(card): Park-pass issuance logic + gate card types by availability
- Park Access is only offered if you own a venue (a spot you authored), and
  publishing REQUIRES a venue binding — only the venue owner's key can issue it.
  The pass names the holder; /verify shows holder + venue match + expiry.
- Card types are now gated by what you can actually issue/derive (Park/ Crew /
  Event), with a note explaining how to unlock more.
- Park panel shows a 'map a spot you own' prompt when you have no venue; the
  Publish button is disabled until a venue + holder are set.
- NIP.md: venue binding is required for new passes. i18n EN/ES.
2026-10-02 12:42:53 +07:00

218 lines
9.8 KiB
TypeScript

/**
* VerifyCardPage — /verify/:naddr
*
* Anyone can open this (scan the QR on a printed card) to check a Park Access
* Card or Rider Card: it fetches the event, verifies the signature, and — for a
* park pass — confirms the issuer really owns the venue it names.
*
* It is intentionally honest about what it does and does NOT prove.
*/
import { useParams, 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, MapPin } 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_CREW_KIND, CALENDAR_EVENT_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;
}
}
export default function VerifyCardPage() {
const { t } = useTranslation();
const { naddr = '' } = useParams<{ naddr: string }>();
const { nostr } = useNostr();
const parsed = parseNaddr(naddr);
useSeoMeta({ title: 'Verify a card — SkateSpots', description: 'Verify a SkateSpots Rider Card or Park Access Card.' });
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 };
// Park pass — confirm the issuer owns the venue it names, when it names one.
let venue: { status: 'none' | 'verified' | 'mismatch'; title?: string; coord?: string } = { 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(':');
if (k === String(SKATE_SPOT_KIND) && pk && dTag) {
try {
const spots = await nostr.query(
[{ kinds: [SKATE_SPOT_KIND], authors: [pk], '#d': [dTag], limit: 1 }],
{ signal },
);
const spot = spots[0] as NostrEvent | undefined;
if (spot) {
venue = { status: spot.pubkey === event.pubkey ? 'verified' : 'mismatch', title: spot.tags.find(([n]) => n === 'title')?.[1] || spot.tags.find(([n]) => n === 'name')?.[1], coord: aTag };
}
} catch { /* leave mismatch */ }
}
}
}
return { status: 'valid' as const, event, venue };
},
});
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 holderPubkey = event?.tags.find(([n]) => n === 'p')?.[1];
const holder = holderPubkey ? nip19.npubEncode(holderPubkey) : '';
return (
<div className="max-w-lg mx-auto px-4 py-10">
<div className="text-center mb-6">
<h1 className="text-3xl font-black uppercase tracking-tight">
<span className="neon-red">VERIFY</span> <span className="text-white">CARD</span>
</h1>
</div>
{!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>
)}
{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 && (
<>
<StatusCard tone="good" icon={<BadgeCheck className="h-10 w-10" />} title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />
<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 === RIDER_CARD_KIND && (
<Row label={t('cardUi.verify.selfIssued')} value={t('cardUi.verify.selfIssuedDesc')} />
)}
</div>
{/* Venue binding (park passes) */}
{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">
<MapPin className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<div className="font-bold text-white">{t('cardUi.verify.venue')}</div>
{venue.status === 'verified' && (
<div className="text-green-400 mt-0.5">✓ {t('cardUi.verify.venueVerified', { name: venue.title ?? '' })}</div>
)}
{venue.status === 'mismatch' && (
<div className="text-amber-400 mt-0.5">{t('cardUi.verify.venueMismatch')}</div>
)}
{venue.status === 'none' && (
<div className="text-gray-400 mt-0.5">{t('cardUi.verify.venueNone')}</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>
);
}
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 }) {
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-sm font-bold text-white text-right">{value}</span>
</div>
);
}