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',
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 className="mx-auto max-w-lg px-4 py-6">
|
||||
{/* Verdict hero — the whole point of the page */}
|
||||
{parsed && result.isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-gray-400">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="text-xs font-black uppercase tracking-widest">{t('cardUi.verify.checking')}</span>
|
||||
</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 && (
|
||||
) : (
|
||||
<>
|
||||
{revoked
|
||||
? <StatusCard tone="bad" icon={<ShieldX className="h-10 w-10" />} title={t('cardUi.verify.revoked')} body={t('cardUi.verify.revokedDesc')} />
|
||||
: <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 === 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
|
||||
label={t('cardUi.verify.rsvp')}
|
||||
value={
|
||||
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 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>
|
||||
<p className="mx-auto mt-2 max-w-xs text-sm text-gray-300">{sub}</p>
|
||||
</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">{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>
|
||||
{event && (
|
||||
<>
|
||||
{/* Facts */}
|
||||
{facts.filter((f) => f.value).length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{facts.filter((f) => f.value).map((f) => (
|
||||
<div key={f.label} className="rounded-xl border border-white/10 bg-black/40 px-3 py-3">
|
||||
<div className="text-[10px] uppercase tracking-widest text-gray-500">{f.label}</div>
|
||||
<div className="mt-1 break-words text-sm font-bold text-white">{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
label={venue.kind === CALENDAR_EVENT_KIND ? t('cardUi.verify.organiser') : t('cardUi.verify.venue')}
|
||||
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 && (
|
||||
<Row
|
||||
label={t('cardUi.verify.rsvp')}
|
||||
value={ticket.data.status === 'going' ? t('cardUi.verify.rsvpGoing') : ticket.data.status === 'interested' ? t('cardUi.verify.rsvpInterested') : t('cardUi.verify.rsvpNone')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
{parsed && (
|
||||
isCrew ? (
|
||||
<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" />
|
||||
</Link>
|
||||
) : isTicket ? (
|
||||
<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" />
|
||||
</Link>
|
||||
) : (
|
||||
<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" />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
<p className="mt-1 text-center text-[11px] text-gray-600">{t('cardUi.verify.disclaimer')}</p>
|
||||
</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>
|
||||
<span className="text-right text-sm font-bold text-white">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue