- /card gains a third type: Crew. Pick one of your crews and render a printable crew card (name, logo, home turf, founded, members, spots, vibes, Open call). - No new event: the card is derived from the crew's own signed kind 35006, so its QR verifies that event (the founder's key IS the signature). - Rendered in the React card + canvas export; /verify/:naddr now renders kind 35006 with a link to the crew page. - i18n cardUi.crew.* / verify.kindCrew; NIP.md + AGENTS.md updated.
204 lines
8.9 KiB
TypeScript
204 lines
8.9 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 } from '@/lib/riderCard';
|
|
import { SKATE_SPOT_KIND, SKATE_CREW_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;
|
|
|
|
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') : t('cardUi.verify.kindRider')} />
|
|
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
|
|
{event.kind === PARK_ACCESS_CARD_KIND && (
|
|
<>
|
|
<Row label={t('cardUi.verify.accessLevel')} value={event.tags.find(([n]) => n === 'access-level')?.[1] ?? '—'} />
|
|
{event.tags.find(([n]) => n === 'valid-thru')?.[1] && (
|
|
<Row label={t('cardUi.verify.validThru')} value={event.tags.find(([n]) => n === 'valid-thru')?.[1] ?? ''} />
|
|
)}
|
|
</>
|
|
)}
|
|
{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>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|