- 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.
218 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|