feat(verify): verifier-first /verify screen for door staff
Redesigned around the ONE question a verifier needs in half a second: - Big verdict hero (✅ Valid / ❌ Expired / Revoked / Invalid / ⚠ Unverified issuer / Not bound / No RSVP / Admitted / Authentic) with a plain-language line. - Facts grid (holder, venue/organiser, valid-thru, access level) large and legible. - Technical detail moved into a collapsed 'Details' section below. - Combined verdict for access passes (signature + not revoked + not expired + issuer owns the venue) and event tickets (organiser's event + holder RSVP). No account/app needed; NFC tap and QR both land here.
This commit is contained in:
parent
38ae1273ff
commit
7f9b5d9ccc
3 changed files with 178 additions and 119 deletions
|
|
@ -6339,6 +6339,26 @@ walletUi: {
|
||||||
parkRevoke: 'Revoke',
|
parkRevoke: 'Revoke',
|
||||||
parkRevoked: 'Revoked',
|
parkRevoked: 'Revoked',
|
||||||
parkRevokeFailed: 'Could not revoke',
|
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: {
|
passes: {
|
||||||
tab: 'My passes',
|
tab: 'My passes',
|
||||||
pass: 'Access pass',
|
pass: 'Access pass',
|
||||||
|
|
@ -6464,6 +6484,9 @@ walletUi: {
|
||||||
venueNone: 'Not bound to a venue.',
|
venueNone: 'Not bound to a venue.',
|
||||||
viewCard: 'View the card',
|
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.',
|
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: {
|
public: {
|
||||||
invalid: 'That doesn’t look like a valid card link.',
|
invalid: 'That doesn’t look like a valid card link.',
|
||||||
|
|
|
||||||
|
|
@ -6339,6 +6339,26 @@ walletUi: {
|
||||||
parkRevoke: 'Revocar',
|
parkRevoke: 'Revocar',
|
||||||
parkRevoked: 'Revocado',
|
parkRevoked: 'Revocado',
|
||||||
parkRevokeFailed: 'No se pudo revocar',
|
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: {
|
passes: {
|
||||||
tab: 'Mis pases',
|
tab: 'Mis pases',
|
||||||
pass: 'Pase de acceso',
|
pass: 'Pase de acceso',
|
||||||
|
|
@ -6464,6 +6484,9 @@ walletUi: {
|
||||||
venueNone: 'Sin vincular a ninguna sede.',
|
venueNone: 'Sin vincular a ninguna sede.',
|
||||||
viewCard: 'Ver el carné',
|
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.',
|
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: {
|
public: {
|
||||||
invalid: 'Eso no parece un enlace de carné válido.',
|
invalid: 'Eso no parece un enlace de carné válido.',
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* VerifyCardPage — /verify/:naddr
|
* VerifyCardPage — /verify/:naddr
|
||||||
*
|
*
|
||||||
* Anyone can open this (scan the QR on a printed card) to check a Park Access
|
* VERIFIER-FIRST: a door person scans a QR (or taps an NFC tag) and needs one
|
||||||
* Card or Rider Card: it fetches the event, verifies the signature, and — for a
|
* answer in half a second — ✅ / ❌ — then the facts. The technical detail is
|
||||||
* park pass — confirms the issuer really owns the venue it names.
|
* 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 { useParams, useSearchParams, Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { nip19, verifyEvent } from 'nostr-tools';
|
import { nip19, verifyEvent } from 'nostr-tools';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
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 { useNostr } from '@/hooks/useNostr';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
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() {
|
export default function VerifyCardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { naddr = '' } = useParams<{ naddr: string }>();
|
const { naddr = '' } = useParams<{ naddr: string }>();
|
||||||
|
|
@ -52,7 +59,7 @@ export default function VerifyCardPage() {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
const parsed = parseNaddr(naddr);
|
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({
|
const result = useQuery({
|
||||||
queryKey: ['verify-card', naddr],
|
queryKey: ['verify-card', naddr],
|
||||||
|
|
@ -130,131 +137,137 @@ export default function VerifyCardPage() {
|
||||||
const venue = valid?.venue;
|
const venue = valid?.venue;
|
||||||
const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1];
|
const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1];
|
||||||
const expiry = passExpiry(validThru);
|
const expiry = passExpiry(validThru);
|
||||||
|
const accessLevel = event?.tags.find(([n]) => n === 'access-level')?.[1];
|
||||||
const holderPubkey = event?.tags.find(([n]) => n === 'p')?.[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 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 (
|
return (
|
||||||
<div className="max-w-lg mx-auto px-4 py-10">
|
<div className="mx-auto max-w-lg px-4 py-6">
|
||||||
<div className="text-center mb-6">
|
{/* Verdict hero — the whole point of the page */}
|
||||||
<h1 className="text-3xl font-black uppercase tracking-tight">
|
{parsed && result.isLoading ? (
|
||||||
<span className="neon-red">VERIFY</span> <span className="text-white">CARD</span>
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-gray-400">
|
||||||
</h1>
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest">{t('cardUi.verify.checking')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{!parsed && (
|
<>
|
||||||
<StatusCard tone="warn" icon={<ShieldAlert className="h-10 w-10" />} title={t('cardUi.verify.notFound')} body={t('cardUi.verify.notFoundDesc')} />
|
<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>
|
||||||
{parsed && result.isLoading && (
|
<p className="mx-auto mt-2 max-w-xs text-sm text-gray-300">{sub}</p>
|
||||||
<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>
|
</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 && (
|
{event && (
|
||||||
<>
|
<>
|
||||||
{revoked
|
{/* Facts */}
|
||||||
? <StatusCard tone="bad" icon={<ShieldX className="h-10 w-10" />} title={t('cardUi.verify.revoked')} body={t('cardUi.verify.revokedDesc')} />
|
{facts.filter((f) => f.value).length > 0 && (
|
||||||
: <StatusCard tone="good" icon={<BadgeCheck className="h-10 w-10" />} title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />}
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
{facts.filter((f) => f.value).map((f) => (
|
||||||
<div className="mt-5 rounded-2xl border border-white/10 bg-black/40 divide-y divide-white/5">
|
<div key={f.label} className="rounded-xl border border-white/10 bg-black/40 px-3 py-3">
|
||||||
<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')} />
|
<div className="text-[10px] uppercase tracking-widest text-gray-500">{f.label}</div>
|
||||||
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
|
<div className="mt-1 break-words text-sm font-bold text-white">{f.value}</div>
|
||||||
{event.kind === PARK_ACCESS_CARD_KIND && (
|
</div>
|
||||||
<>
|
))}
|
||||||
{holder && (
|
</div>
|
||||||
<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 && (
|
{/* 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
|
<Row
|
||||||
label={t('cardUi.verify.validThru')}
|
label={venue.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : t('cardUi.verify.venue')}
|
||||||
value={expiry === 'expired' ? `${validThru} · ${t('cardUi.verify.expired')}` : validThru}
|
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 && (
|
||||||
)}
|
|
||||||
{event.kind === CALENDAR_EVENT_KIND && holderHex && (
|
|
||||||
<>
|
|
||||||
<Row label={t('cardUi.verify.ticketHolder')} value={`${nip19.npubEncode(holderHex).slice(0, 14)}…${nip19.npubEncode(holderHex).slice(-6)}`} />
|
|
||||||
{ticket.data && (
|
|
||||||
<Row
|
<Row
|
||||||
label={t('cardUi.verify.rsvp')}
|
label={t('cardUi.verify.rsvp')}
|
||||||
value={
|
value={ticket.data.status === 'going' ? t('cardUi.verify.rsvpGoing') : ticket.data.status === 'interested' ? t('cardUi.verify.rsvpInterested') : t('cardUi.verify.rsvpNone')}
|
||||||
ticket.data.status === 'going' ? t('cardUi.verify.rsvpGoing')
|
|
||||||
: ticket.data.status === 'interested' ? t('cardUi.verify.rsvpInterested')
|
|
||||||
: t('cardUi.verify.rsvpNone')
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{event.kind === RIDER_CARD_KIND && (
|
|
||||||
<Row label={t('cardUi.verify.selfIssued')} value={t('cardUi.verify.selfIssuedDesc')} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* Venue binding (park passes) */}
|
<div className="mt-5 flex flex-col gap-2">
|
||||||
{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">{venue.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : 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 && (
|
||||||
parsed.kind === SKATE_CREW_KIND ? (
|
isCrew ? (
|
||||||
<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">
|
<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" />
|
{t('cardUi.crew.viewCrew')} <ExternalLink className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
) : parsed.kind === CALENDAR_EVENT_KIND ? (
|
) : isTicket ? (
|
||||||
<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">
|
<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" />
|
{t('cardUi.event.viewEvent')} <ExternalLink className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</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">
|
<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" />
|
{t('cardUi.verify.viewCard')} <ExternalLink className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-gray-600 text-center mt-1">{t('cardUi.verify.disclaimer')}</p>
|
<p className="mt-1 text-center text-[11px] text-gray-600">{t('cardUi.verify.disclaimer')}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -263,7 +276,7 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
<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-[11px] uppercase tracking-widest text-gray-500">{label}</span>
|
||||||
<span className="text-sm font-bold text-white text-right">{value}</span>
|
<span className="text-right text-sm font-bold text-white">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue