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