From 68922280cb80cefec2149f12dc7d3a4fd53965df Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 16 Jun 2026 15:33:02 +0700 Subject: [PATCH] Fix card layout, add real QR code, npub as identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ — 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/ for QR content Co-authored-by: shakespeare.diy --- src/pages/RiderCardPage.tsx | 318 +++++++++++++++++++----------------- 1 file changed, 171 insertions(+), 147 deletions(-) diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index ca93777..f7f3ae2 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -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 ( -
- {/* Header row */} -
+ // absolute inset — fills the card exactly +
+ + {/* ── Row 1: header (26px) ── */} +
-
- - 🛹 SkateSpots - +
+ 🛹 SkateSpots
-
Rider License
+
Rider License
-
LICENSE ID
-
{d.licenseId}
-
{shortNpub(d.pubkey)}
+
npub
+
+ {shortNpub(d.pubkey)} +
+
Since {d.memberYear}
- {/* Main content */} -
+ {/* ── Row 2: main body (118px) ── */} +
+ {/* Avatar */} -
-
+
+
{d.avatar ? ( - {d.name} + {d.name} ) : ( -
- 🛹 +
🛹
+ )} +
+ {/* Stats under avatar */} +
+ {d.showSpots && ( +
+ Spots + {d.spotsAdded} +
+ )} + {d.showZaps && ( +
+ ⚡ + {d.zapsReceived} +
+ )} + {d.showEvents && ( +
+ Evts + {d.eventsCreated}
)}
- {/* Identity */} -
-
{d.name}
+ {/* Identity block */} +
+
{d.name}
{d.nip05 && ( -
@{d.nip05.split('@')[0]}
+
@{d.nip05.split('@')[0]}
+ )} + {d.style && ( + + {d.style} + )} - - {/* Style + year */} -
- {d.style && ( - - {d.style} - - )} - Since {d.memberYear} -
- - {/* Rides for */} {d.ridesFor && ( -
- 🏪 {d.ridesFor} -
+
🏪 {d.ridesFor}
)} + {d.sponsors && ( +
🤝 {d.sponsors}
+ )} + {/* Skills */} +
+ {d.skills.slice(0, 4).map(sk => { + const s = SKILL_OPTIONS.find(o => o.id === sk); + return s ? ( + + {s.icon} {s.label} + + ) : null; + })} + {witnessedBadges.map((b, i) => { + const s = SKILL_OPTIONS.find(o => o.id === b.skill); + return s ? ( + + ⭐ {s.label} + + ) : null; + })} +
- {/* Stats column */} -
- {d.showSpots && ( -
- Spots - {d.spotsAdded} -
- )} - {d.showZaps && ( -
- ⚡ - {d.zapsReceived} -
- )} - {d.showEvents && ( -
- Events - {d.eventsCreated} -
- )} + {/* QR code — bottom-right of body */} +
+
+ +
+
Scan to verify
- {/* Skills + witnessed badges row */} -
- {/* Self-selected skills */} - {d.skills.slice(0, 5).map(sk => { - const s = SKILL_OPTIONS.find(o => o.id === sk); - return s ? ( - - {s.icon} {s.label} - - ) : null; - })} - {/* Witnessed badges */} - {witnessedBadges.map((b, i) => { - const s = SKILL_OPTIONS.find(o => o.id === b.skill); - return s ? ( - - ⭐ {s.label} - - ) : null; - })} + {/* ── Bottom bar: npub + website (absolute at bottom) ── */} +
+ + skate-spots.org · Powered by Nostr + + + {shortNpub(d.pubkey)} +
- - {/* Sponsors row */} - {d.sponsors && ( -
- 🤝 {d.sponsors} -
- )}
); } // ─── Card visual: Park Access front ────────────────────────────────────────── function ParkFront({ d }: { d: CardData }) { + const qrUrl = verifyUrl(d.pubkey); + return ( -
- {/* Header */} -
+
+ + {/* Row 1: header */} +
-
+
🏟️ {d.issuerName || 'Skatepark'}
-
Access Pass
+
Access Pass
{d.accessLevel || 'Full Access'}
- {/* Member info */} -
-
- {d.avatar ? ( - {d.recipientName} - ) : ( -
🛹
+ {/* Row 2: main body */} +
+ + {/* Avatar */} +
+
+ {d.avatar ? ( + {d.recipientName} + ) : ( +
🛹
+ )} +
+
+ + {/* Identity */} +
+
{d.recipientName || 'Member Name'}
+
{shortNpub(d.pubkey)}
+
Issued by park owner · Nostr-signed
+ {d.validThru && ( +
+ Valid thru + {d.validThru} +
)}
-
-
{d.recipientName || 'Member Name'}
-
{shortNpub(d.pubkey)}
-
Issued by park owner · Nostr-signed
-
-
-
Valid thru
-
{d.validThru || '—'}
+ + {/* QR */} +
+
+ +
+
Scan to verify
- {/* License ID + shield */} -
-
-
License ID
-
{d.licenseId}
-
+ {/* Bottom bar */} +
+ + skate-spots.org · Powered by Nostr +
- - Nostr-verified + + Nostr-verified
@@ -358,16 +391,6 @@ function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObje {d.type === 'rider' && } {d.type === 'park' && } - - {/* Bottom micro-text */} -
- - skate-spots.org · Powered by Nostr - - - Scan to verify - -
); } @@ -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 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]);