diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0420b22..bfeafc5 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6339,6 +6339,26 @@ walletUi: { parkRevoke: 'Revoke', parkRevoked: 'Revoked', parkRevokeFailed: 'Could not revoke', + v: { + valid: 'Valid', + validDesc: 'Signature valid and issued by the venue.', + expired: 'Expired', + expiredDesc: 'Past its valid-thru date.', + revoked: 'Revoked', + revokedDesc: 'The issuer cancelled this pass — do not accept it.', + invalid: 'Invalid', + invalidDesc: 'The signature does not verify. Do not trust it.', + notFound: 'Not found', + notFoundDesc: 'No matching record on the relays.', + unverifiedIssuer: 'Unverified issuer', + unverifiedIssuerDesc: 'This pass names a venue not owned by the issuer’s key.', + notBound: 'Not bound', + notBoundDesc: 'Not bound to a venue — proves nothing about access.', + noRsvp: 'No RSVP', + noRsvpDesc: 'The holder has no RSVP on record for this event.', + admitted: 'Admitted', + admittedDesc: 'Organiser’s event + the holder RSVP’d.', + }, passes: { tab: 'My passes', pass: 'Access pass', @@ -6464,6 +6484,9 @@ walletUi: { venueNone: 'Not bound to a venue.', viewCard: 'View the card', disclaimer: 'A valid signature proves who signed this card — it is not proof of skill or of access rights beyond what the venue issued.', + details: 'Details', + invalidLink: 'Invalid link', + invalidLinkDesc: 'That doesn’t look like a valid card link.', }, public: { invalid: 'That doesn’t look like a valid card link.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index c34225e..fceabf2 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6339,6 +6339,26 @@ walletUi: { parkRevoke: 'Revocar', parkRevoked: 'Revocado', parkRevokeFailed: 'No se pudo revocar', + v: { + valid: 'Válido', + validDesc: 'Firma válida y emitido por la sede.', + expired: 'Caducado', + expiredDesc: 'Ha pasado su fecha de validez.', + revoked: 'Revocado', + revokedDesc: 'El emisor canceló este pase — no lo aceptes.', + invalid: 'No válido', + invalidDesc: 'La firma no se verifica. No te fíes.', + notFound: 'No encontrado', + notFoundDesc: 'No hay registro en los relés.', + unverifiedIssuer: 'Emisor sin verificar', + unverifiedIssuerDesc: 'Este pase nombra una sede que no pertenece a la clave del emisor.', + notBound: 'Sin vincular', + notBoundDesc: 'Sin vincular a una sede — no prueba nada sobre el acceso.', + noRsvp: 'Sin confirmación', + noRsvpDesc: 'El titular no tiene confirmación registrada para este evento.', + admitted: 'Admitido', + admittedDesc: 'Evento del organizador + el titular confirmó.', + }, passes: { tab: 'Mis pases', pass: 'Pase de acceso', @@ -6464,6 +6484,9 @@ walletUi: { venueNone: 'Sin vincular a ninguna sede.', viewCard: 'Ver el carné', disclaimer: 'Una firma válida demuestra quién firmó este carné — no es prueba de habilidad ni de derechos de acceso más allá de lo que emitió la sede.', + details: 'Detalles', + invalidLink: 'Enlace no válido', + invalidLinkDesc: 'Eso no parece un enlace de carné válido.', }, public: { invalid: 'Eso no parece un enlace de carné válido.', diff --git a/src/pages/VerifyCardPage.tsx b/src/pages/VerifyCardPage.tsx index 0d95855..a37476a 100644 --- a/src/pages/VerifyCardPage.tsx +++ b/src/pages/VerifyCardPage.tsx @@ -1,17 +1,17 @@ /** * 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. + * 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 intentionally honest about what it does and does NOT prove. + * 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, MapPin } from 'lucide-react'; +import { BadgeCheck, ShieldAlert, ShieldX, Loader2, ExternalLink } from 'lucide-react'; import { useNostr } from '@/hooks/useNostr'; import { useAuthor } from '@/hooks/useAuthor'; @@ -39,6 +39,13 @@ function parseNaddr(value: string): ParsedNaddr | 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 }>(); @@ -52,7 +59,7 @@ export default function VerifyCardPage() { const { nostr } = useNostr(); const parsed = parseNaddr(naddr); - useSeoMeta({ title: 'Verify a card — SkateSpots', description: 'Verify a SkateSpots Rider Card or Park Access Card.' }); + useSeoMeta({ title: 'Verify — SkateSpots', description: 'Verify a SkateSpots card or access pass.' }); const result = useQuery({ queryKey: ['verify-card', naddr], @@ -130,140 +137,146 @@ export default function VerifyCardPage() { 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 holder = holderPubkey ? nip19.npubEncode(holderPubkey) : ''; 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 && holderHex && ticket.data?.status === 'none') { tone = 'warn'; label = t('cardUi.v.noRsvp'); sub = t('cardUi.v.noRsvpDesc'); } + else if (isTicket) { tone = 'good'; label = t('cardUi.v.admitted'); sub = t('cardUi.v.admittedDesc'); } + 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 ( -
-
-

- VERIFY CARD -

-
- - {!parsed && ( - } title={t('cardUi.verify.notFound')} body={t('cardUi.verify.notFoundDesc')} /> - )} - - {parsed && result.isLoading && ( -
- {t('cardUi.verify.checking')} +
+ {/* Verdict hero — the whole point of the page */} + {parsed && result.isLoading ? ( +
+ + {t('cardUi.verify.checking')}
- )} - - {parsed && !result.isLoading && data?.status === 'notfound' && ( - } title={t('cardUi.verify.notFound')} body={t('cardUi.verify.notFoundDesc')} /> - )} - - {parsed && data?.status === 'invalid' && ( - } title={t('cardUi.verify.invalid')} body={t('cardUi.verify.invalidDesc')} /> - )} - - {event && ( + ) : ( <> - {revoked - ? } title={t('cardUi.verify.revoked')} body={t('cardUi.verify.revokedDesc')} /> - : } title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />} - -
- - - {event.kind === PARK_ACCESS_CARD_KIND && ( - <> - {holder && ( - - )} - n === 'access-level')?.[1] ?? '—'} /> - {validThru && ( - - )} - - )} - {event.kind === CALENDAR_EVENT_KIND && holderHex && ( - <> - - {ticket.data && ( - - )} - - )} - {event.kind === RIDER_CARD_KIND && ( - - )} +
+ +
{label}
+

{sub}

- {/* Venue binding (park passes) */} - {event.kind === PARK_ACCESS_CARD_KIND && venue && ( -
- -
-
{venue.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : t('cardUi.verify.venue')}
- {venue.status === 'verified' && ( -
✓ {t('cardUi.verify.venueVerified', { name: venue.title ?? '' })}
- )} - {venue.status === 'mismatch' && ( -
{t('cardUi.verify.venueMismatch')}
- )} - {venue.status === 'none' && ( -
{t('cardUi.verify.venueNone')}
+ {event && ( + <> + {/* Facts */} + {facts.filter((f) => f.value).length > 0 && ( +
+ {facts.filter((f) => f.value).map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+ )} + + {/* Technical detail (collapsed) */} +
+ + {t('cardUi.verify.details')} + +
+ + + {isPark && venue && ( + + )} + {isTicket && ticket.data && ( + + )} +
+
+ +
+ {parsed && ( + isCrew ? ( + + {t('cardUi.crew.viewCrew')} + + ) : isTicket ? ( + + {t('cardUi.event.viewEvent')} + + ) : ( + + {t('cardUi.verify.viewCard')} + + ) )} +

{t('cardUi.verify.disclaimer')}

-
+ )} - -
- {parsed && ( - parsed.kind === SKATE_CREW_KIND ? ( - - {t('cardUi.crew.viewCrew')} - - ) : parsed.kind === CALENDAR_EVENT_KIND ? ( - - {t('cardUi.event.viewEvent')} - - ) : ( - - {t('cardUi.verify.viewCard')} - - ) - )} -

{t('cardUi.verify.disclaimer')}

-
)}
); } -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 ( -
-
{icon}
-
{title}
-

{body}

-
- ); -} - function Row({ label, value }: { label: string; value: string }) { return (
{label} - {value} + {value}
); }