Fix card layout, add real QR code, npub as identity

Problems fixed:
- Card was rendering too tall: replaced flex-1/justify-between layout with
  strict pixel-height rows (row1: 26px header, row2: 118px body, fixed bottom bar)
  — content no longer floats or overflows
- No QR code: added QRCodeCanvas (existing component) in bottom-right of card
  body, encoding https://skate-spots.org/<npub> — scanning takes you to the
  rider's profile for verification
- License ID removed: replaced SS-XXXXXXX with shortNpub (npub1xxx...yyyy)
  in top-right. The npub IS the identity — no made-up number needed
- Added crossOrigin="anonymous" on avatar img for html2canvas CORS handling
- Updated html2canvas onclone to lock card to 340×214px during capture
- Download filename uses npub prefix instead of licenseId
- verifyUrl() helper generates skate-spots.org/<npub> for QR content

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 15:33:02 +07:00
parent 046a109eca
commit 68922280cb

View file

@ -41,6 +41,7 @@ import { useQuery } from '@tanstack/react-query';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import {
Printer, Download, Copy, Palette, Check, Building2,
@ -87,19 +88,24 @@ const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const
function shortNpub(pubkey: string) {
if (!pubkey) return '—';
const npub = nip19.npubEncode(pubkey);
return `${npub.slice(0, 10)}…${npub.slice(-4)}`;
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
}
function riderLicenseId(pubkey: string) {
// Deterministic license ID from pubkey — human-readable, not a secret
const n = parseInt(pubkey.slice(0, 8), 16) % 9000000 + 1000000;
return `SS-${n}`;
function fullNpub(pubkey: string) {
if (!pubkey) return '';
return nip19.npubEncode(pubkey);
}
function memberSince(createdAt: number) {
return new Date(createdAt * 1000).getFullYear().toString();
}
// Verification URL — scanning this on a phone opens the profile on skate-spots.org
function verifyUrl(pubkey: string) {
if (!pubkey) return 'https://skate-spots.org';
return `https://skate-spots.org/${nip19.npubEncode(pubkey)}`;
}
// ─── Badge award event type ───────────────────────────────────────────────────
interface BadgeAward {
witnessName: string;
@ -119,6 +125,7 @@ interface CardData {
nip05?: string;
avatar?: string;
pubkey: string;
npub: string;
style: string;
skills: string[];
sponsors: string;
@ -130,7 +137,6 @@ interface CardData {
zapsReceived: number;
eventsCreated: number;
memberYear: string;
licenseId: string;
receivedBadges: BadgeAward[];
// Park
issuerName: string;
@ -167,175 +173,202 @@ function useWitnessName(pubkey: string) {
}
// ─── Card visual: Rider front ─────────────────────────────────────────────────
// Layout is STRICTLY pixel-fixed to match the 340×214px card container.
// No flex-1 / flex-grow — every row has a fixed pixel height.
function RiderFront({ d }: { d: CardData }) {
// Only show first 2 witnessed badges on the card itself
const witnessedBadges = d.receivedBadges.slice(0, 3);
const witnessedBadges = d.receivedBadges.slice(0, 2);
const qrUrl = verifyUrl(d.pubkey);
return (
<div className="absolute inset-0 p-[10px] flex flex-col justify-between">
{/* Header row */}
<div className="flex items-start justify-between">
// absolute inset — fills the card exactly
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* ── Row 1: header (26px) ── */}
<div className="flex items-start justify-between" style={{ height: '26px' }}>
<div>
<div className="flex items-center gap-1 mb-0.5">
<span className="text-[8px] font-black uppercase tracking-[0.2em] card-accent">
🛹 SkateSpots
</span>
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest">Rider License</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Rider License</div>
</div>
<div className="text-right">
<div className="text-[5.5px] card-muted uppercase tracking-wide">LICENSE ID</div>
<div className="text-[7.5px] font-black card-accent tracking-wider">{d.licenseId}</div>
<div className="text-[5px] card-muted font-mono mt-0.5">{shortNpub(d.pubkey)}</div>
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
{shortNpub(d.pubkey)}
</div>
<div className="text-[5px] card-muted mt-[1px]">Since {d.memberYear}</div>
</div>
</div>
{/* Main content */}
<div className="flex items-center gap-[8px] flex-1 my-[4px]">
{/* ── Row 2: main body (118px) ── */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Avatar */}
<div className="relative flex-shrink-0">
<div className="w-[44px] h-[44px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
<div className="flex-shrink-0 flex flex-col gap-[4px]">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" />
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[14px]">
🛹
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
)}
</div>
{/* Stats under avatar */}
<div className="space-y-[2px]">
{d.showSpots && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">Spots</span>
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
</div>
)}
{d.showZaps && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">⚡</span>
<span className="text-[7px] font-black card-accent2 leading-none">{d.zapsReceived}</span>
</div>
)}
{d.showEvents && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">Evts</span>
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
</div>
)}
</div>
</div>
{/* Identity */}
<div className="flex-1 min-w-0">
<div className="text-[12px] font-black uppercase truncate leading-tight">{d.name}</div>
{/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</div>
{d.nip05 && (
<div className="text-[6.5px] card-accent truncate mt-0.5">@{d.nip05.split('@')[0]}</div>
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div>
)}
{d.style && (
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent w-fit">
{d.style}
</span>
)}
{/* Style + year */}
<div className="flex items-center gap-1.5 mt-[3px]">
{d.style && (
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent">
{d.style}
</span>
)}
<span className="card-muted text-[5.5px]">Since {d.memberYear}</span>
</div>
{/* Rides for */}
{d.ridesFor && (
<div className="text-[5.5px] card-muted truncate mt-[2px]">
🏪 {d.ridesFor}
</div>
<div className="text-[5.5px] card-muted truncate leading-none">🏪 {d.ridesFor}</div>
)}
{d.sponsors && (
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
)}
{/* Skills */}
<div className="flex flex-wrap gap-[2px] mt-[2px]">
{d.skills.slice(0, 4).map(sk => {
const s = SKILL_OPTIONS.find(o => o.id === sk);
return s ? (
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
{s.icon} {s.label}
</span>
) : null;
})}
{witnessedBadges.map((b, i) => {
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
return s ? (
<span
key={`w${i}`}
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none"
style={{ background: 'rgba(255,215,0,0.2)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
>
⭐ {s.label}
</span>
) : null;
})}
</div>
</div>
{/* Stats column */}
<div className="flex-shrink-0 text-right space-y-[3px]">
{d.showSpots && (
<div className="flex items-center justify-end gap-1">
<span className="text-[5.5px] card-muted">Spots</span>
<span className="text-[7.5px] font-black card-accent">{d.spotsAdded}</span>
</div>
)}
{d.showZaps && (
<div className="flex items-center justify-end gap-1">
<span className="text-[5.5px] card-muted">⚡</span>
<span className="text-[7.5px] font-black card-accent2">{d.zapsReceived}</span>
</div>
)}
{d.showEvents && (
<div className="flex items-center justify-end gap-1">
<span className="text-[5.5px] card-muted">Events</span>
<span className="text-[7.5px] font-black" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
</div>
)}
{/* QR code — bottom-right of body */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<div className="rounded-[3px] overflow-hidden" style={{ background: '#fff', padding: '2px' }}>
<QRCodeCanvas value={qrUrl} size={44} level="M" />
</div>
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">Scan to verify</div>
</div>
</div>
{/* Skills + witnessed badges row */}
<div className="flex flex-wrap gap-[2.5px]">
{/* Self-selected skills */}
{d.skills.slice(0, 5).map(sk => {
const s = SKILL_OPTIONS.find(o => o.id === sk);
return s ? (
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px]">
{s.icon} {s.label}
</span>
) : null;
})}
{/* Witnessed badges */}
{witnessedBadges.map((b, i) => {
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
return s ? (
<span
key={i}
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px]"
style={{ background: 'rgba(255,215,0,0.15)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
title={`Witnessed by ${b.witnessName}${b.spotName ? ` at ${b.spotName}` : ''}`}
>
⭐ {s.label}
</span>
) : null;
})}
{/* ── Bottom bar: npub + website (absolute at bottom) ── */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5px] font-mono" style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shortNpub(d.pubkey)}
</span>
</div>
{/* Sponsors row */}
{d.sponsors && (
<div className="text-[5.5px] card-muted truncate mt-[2px]">
🤝 {d.sponsors}
</div>
)}
</div>
);
}
// ─── Card visual: Park Access front ──────────────────────────────────────────
function ParkFront({ d }: { d: CardData }) {
const qrUrl = verifyUrl(d.pubkey);
return (
<div className="absolute inset-0 p-[10px] flex flex-col justify-between">
{/* Header */}
<div className="flex items-center justify-between">
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* Row 1: header */}
<div className="flex items-center justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent">
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
🏟️ {d.issuerName || 'Skatepark'}
</div>
<div className="text-[6px] card-muted uppercase tracking-widest">Access Pass</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Access Pass</div>
</div>
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
<span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
</div>
</div>
{/* Member info */}
<div className="flex items-center gap-[8px] flex-1 my-[6px]">
<div className="w-[46px] h-[46px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20 flex-shrink-0">
{d.avatar ? (
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[14px]">🛹</div>
{/* Row 2: main body */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Avatar */}
<div className="flex-shrink-0">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
)}
</div>
</div>
{/* Identity */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || 'Member Name'}</div>
<div className="text-[5.5px] card-muted font-mono leading-none truncate">{shortNpub(d.pubkey)}</div>
<div className="text-[5.5px] card-muted leading-none">Issued by park owner · Nostr-signed</div>
{d.validThru && (
<div className="flex items-center gap-1 mt-[3px]">
<span className="text-[5.5px] card-muted uppercase">Valid thru</span>
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-black uppercase truncate">{d.recipientName || 'Member Name'}</div>
<div className="text-[5.5px] card-muted font-mono mt-0.5 truncate">{shortNpub(d.pubkey)}</div>
<div className="text-[5.5px] card-muted mt-1">Issued by park owner · Nostr-signed</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-[6px] card-muted uppercase">Valid thru</div>
<div className="text-[9px] font-black card-accent">{d.validThru || '—'}</div>
{/* QR */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<div className="rounded-[3px] overflow-hidden" style={{ background: '#fff', padding: '2px' }}>
<QRCodeCanvas value={qrUrl} size={44} level="M" />
</div>
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">Scan to verify</div>
</div>
</div>
{/* License ID + shield */}
<div className="flex items-center justify-between">
<div>
<div className="text-[5.5px] card-muted uppercase">License ID</div>
<div className="text-[8px] font-black card-accent tracking-wider">{d.licenseId}</div>
</div>
{/* Bottom bar */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<div className="flex items-center gap-1">
<Shield className="h-[10px] w-[10px] card-accent opacity-60" />
<span className="text-[6px] card-muted">Nostr-verified</span>
<Shield className="card-muted" style={{ width: '8px', height: '8px', opacity: 0.5 }} />
<span className="card-muted text-[5px] uppercase tracking-widest">Nostr-verified</span>
</div>
</div>
</div>
@ -358,16 +391,6 @@ function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObje
{d.type === 'rider' && <RiderFront d={d} />}
{d.type === 'park' && <ParkFront d={d} />}
{/* Bottom micro-text */}
<div className="absolute bottom-[6px] left-[10px] right-[10px] flex items-center justify-between">
<span className="card-muted text-[5.5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5.5px] uppercase tracking-widest">
Scan to verify
</span>
</div>
</div>
);
}
@ -658,6 +681,7 @@ export default function RiderCardPage() {
})();
// ── Computed card data ───────────────────────────────────────────────────────
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
const cardData: CardData = {
type: cardType,
theme,
@ -665,7 +689,8 @@ export default function RiderCardPage() {
name: cardType === 'rider' ? displayName : recipientName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
pubkey: cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey,
pubkey: cardPubkey,
npub: cardPubkey ? fullNpub(cardPubkey) : '',
style,
skills,
sponsors,
@ -677,7 +702,6 @@ export default function RiderCardPage() {
memberYear: metadata?.created_at
? memberSince(metadata.created_at as number)
: new Date().getFullYear().toString(),
licenseId: user?.pubkey ? riderLicenseId(user.pubkey) : 'SS-0000000',
receivedBadges,
issuerName,
recipientName,
@ -695,18 +719,18 @@ export default function RiderCardPage() {
cardEl.classList.remove('rider-card-holo');
const canvas = await html2canvas(cardEl, {
scale: 4, // 4× for crisp print quality
useCORS: true, // allow cross-origin avatars
scale: 4, // 4× = ~1344×848px — crisp for print
useCORS: true, // allow cross-origin avatar images
allowTaint: true,
backgroundColor: null,
logging: false,
onclone: (doc) => {
// In the cloned doc, force all backgrounds to render
const style = doc.createElement('style');
style.textContent = `
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
`;
doc.head.appendChild(style);
// html2canvas renders <canvas> elements by copying their pixel data
// (works with QRCodeCanvas since it draws synchronously via qrcode lib)
onclone: (_doc, el) => {
// Ensure the cloned element has exact dimensions matching the original
el.style.width = '340px';
el.style.height = '214px';
el.style.overflow = 'hidden';
},
});
@ -772,7 +796,7 @@ export default function RiderCardPage() {
}
const a = document.createElement('a');
a.href = dataUrl;
a.download = `skatespot-card-${cardData.licenseId}.png`;
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
a.click();
}, [renderCardToImage, cardData.licenseId, toast]);