diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 8b0e6fc..20ea53a 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -83,15 +83,16 @@ const THEME_PALETTE: Record = { - thug: { bg1: '#0a0a0a', bg2: '#1a0008', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(255,0,64,0.25)', text: '#ffffff' }, - lime: { bg1: '#050f00', bg2: '#0a1a00', bg3: '#050f00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(204,255,0,0.2)', text: '#ffffff' }, - chrome: { bg1: '#1a1a1a', bg2: '#2e2e2e', bg3: '#111111', accent: '#c8c8c8', accent2: '#ff6600', stripe1: '#888888', stripe2: '#cccccc', muted: 'rgba(232,232,232,0.6)', chipBg: 'rgba(200,200,200,0.15)', text: '#e8e8e8' }, - wood: { bg1: '#3d2008', bg2: '#5c3210', bg3: '#2e1804', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.65)', chipBg: 'rgba(160,100,40,0.3)', text: '#f5e6c8' }, - midnight: { bg1: '#020818', bg2: '#050e2a', bg3: '#030b1a', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#3060cc', stripe2: '#64a0ff', muted: 'rgba(208,228,255,0.6)', chipBg: 'rgba(100,160,255,0.15)', text: '#d0e4ff' }, - sunset: { bg1: '#120006', bg2: '#1a0808', bg3: '#0e0810', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,208,160,0.65)', chipBg: 'rgba(255,80,0,0.2)', text: '#ffd0a0' }, + thug: { bg1: '#0a0a0a', bg2: '#180006', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.5)', chipBg: 'rgba(255,0,64,0.18)', chipBorder: 'rgba(255,0,64,0.45)', text: '#ffffff', shopColor: '#ccff00', sponsorColor: '#ff9900', sidebarBg: 'rgba(255,0,64,0.07)' }, + lime: { bg1: '#040c00', bg2: '#081500', bg3: '#040c00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.5)', chipBg: 'rgba(204,255,0,0.14)', chipBorder: 'rgba(204,255,0,0.4)', text: '#ffffff', shopColor: '#ff6600', sponsorColor: '#ff0040', sidebarBg: 'rgba(204,255,0,0.05)' }, + chrome: { bg1: '#111111', bg2: '#252525', bg3: '#0e0e0e', accent: '#d0d0d0', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.55)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#e8e8e8', shopColor: '#ff9900', sponsorColor: '#64cfff', sidebarBg: 'rgba(200,200,200,0.04)' }, + wood: { bg1: '#2e1804', bg2: '#4a2a0c', bg3: '#261404', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.6)', chipBg: 'rgba(160,100,40,0.25)', chipBorder: 'rgba(200,140,60,0.5)', text: '#f5e6c8', shopColor: '#ff9944', sponsorColor: '#aaffcc', sidebarBg: 'rgba(160,100,40,0.12)' }, + midnight: { bg1: '#010610', bg2: '#040c22', bg3: '#020814', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#2040aa', stripe2: '#64a0ff', muted: 'rgba(200,220,255,0.55)', chipBg: 'rgba(100,160,255,0.12)', chipBorder: 'rgba(100,160,255,0.35)', text: '#d0e4ff', shopColor: '#ccff00', sponsorColor: '#ff88cc', sidebarBg: 'rgba(100,160,255,0.05)' }, + sunset: { bg1: '#0e0004', bg2: '#160606', bg3: '#0a040c', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,200,150,0.6)', chipBg: 'rgba(255,80,0,0.16)', chipBorder: 'rgba(255,80,0,0.4)', text: '#ffd0a0', shopColor: '#ffdd00', sponsorColor: '#ff88ff', sidebarBg: 'rgba(255,80,0,0.07)' }, }; async function loadImage(url: string): Promise { @@ -153,52 +154,353 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe async function drawCardToCanvas(data: CanvasCardData): Promise { const canvas = document.createElement('canvas'); - canvas.width = CARD_W; + canvas.width = CARD_W; canvas.height = CARD_H; const ctx = canvas.getContext('2d')!; const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug; - // ── Background gradient ────────────────────────────────────────────────── - const grad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H); - grad.addColorStop(0, p.bg1); - grad.addColorStop(0.6, p.bg2); - grad.addColorStop(1, p.bg3); + // ─── 1. Background ─────────────────────────────────────────────────────── + const bgGrad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H); + bgGrad.addColorStop(0, p.bg1); + bgGrad.addColorStop(0.5, p.bg2); + bgGrad.addColorStop(1, p.bg3); roundRect(ctx, 0, 0, CARD_W, CARD_H, 40); - ctx.fillStyle = grad; + ctx.fillStyle = bgGrad; ctx.fill(); - // ── Top color stripe (8px) ─────────────────────────────────────────────── + // ─── 2. Left sidebar tint ──────────────────────────────────────────────── + const SIDEBAR_W = 240; + roundRect(ctx, 0, 0, SIDEBAR_W, CARD_H, 40); + ctx.fillStyle = p.sidebarBg; + ctx.fill(); + // vertical separator + ctx.strokeStyle = p.accent; + ctx.globalAlpha = 0.15; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(SIDEBAR_W, 40); ctx.lineTo(SIDEBAR_W, CARD_H - 40); ctx.stroke(); + ctx.globalAlpha = 1; + + // ─── 3. Top stripe ─────────────────────────────────────────────────────── const stripe = ctx.createLinearGradient(0, 0, CARD_W, 0); stripe.addColorStop(0, p.stripe1); stripe.addColorStop(1, p.stripe2); ctx.fillStyle = stripe; - ctx.fillRect(0, 0, CARD_W, 8); + ctx.fillRect(0, 0, CARD_W, 10); - const PAD = 40; - - // ── Generate QR first (data: URI — never proxy) ────────────────────────── - const qrUrl = `https://skate-spots.org/${data.npub}`; - const qrSize = 180; - const qrX = CARD_W - PAD - qrSize; - const qrY = 28; + // ─── 4. QR code (top-right, generated first — data: URI safe) ──────────── + const qrUrl = `https://skate-spots.org/${data.npub}`; + const qrSize = 168; + const qrX = CARD_W - 32 - qrSize; + const qrY = 28; let qrImg: HTMLImageElement | null = null; try { const qrDataUrl = await QRCode.toDataURL(qrUrl, { - width: qrSize * 2, // render at 2× for crispness, then downscale + width: qrSize * 2, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' }, }); - qrImg = await loadImage(qrDataUrl); // data: URI — won't proxy - } catch { /* QR skipped */ } + qrImg = await loadImage(qrDataUrl); // data: URI — no proxy + } catch { /* skip */ } if (qrImg) { - // White padding behind QR roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 10); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize); } + ctx.fillStyle = p.muted; + ctx.font = '13px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 20); + ctx.textAlign = 'left'; + + // ─── 5. Load avatar ─────────────────────────────────────────────────────── + let avatarImg: HTMLImageElement | null = null; + if (data.avatarUrl) avatarImg = await loadImage(data.avatarUrl); + + if (data.type === 'rider') { + // ═══════════════════════════════════════════════════════════════════════ + // SKATER LICENSE LAYOUT + // Left sidebar: avatar + stats + since + // Right zone: name / nip05 / style / shop / sponsors / skills + // ═══════════════════════════════════════════════════════════════════════ + + // ── Left: Avatar ───────────────────────────────────────────────────── + const avSize = 176; + const avX = (SIDEBAR_W - avSize) / 2; + const avY = 22; + roundRect(ctx, avX, avY, avSize, avSize, 14); + ctx.fillStyle = p.chipBg; + ctx.fill(); + if (avatarImg) { + ctx.save(); + roundRect(ctx, avX, avY, avSize, avSize, 14); + ctx.clip(); + ctx.drawImage(avatarImg, avX, avY, avSize, avSize); + ctx.restore(); + } else { + ctx.font = '72px serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = p.accent; + ctx.fillText('🛹', avX + avSize / 2, avY + avSize / 2 + 24); + ctx.textAlign = 'left'; + } + + // ── Left: Stats block ───────────────────────────────────────────────── + const statsStartY = avY + avSize + 20; + const statItems = [ + data.showSpots ? { label: 'SPOTS', value: String(data.spotsAdded), color: p.accent } : null, + data.showZaps ? { label: 'ZAPS ⚡', value: String(data.zapsReceived), color: p.accent2 } : null, + data.showEvents ? { label: 'EVENTS', value: String(data.eventsCreated), color: '#ff7733' } : null, + ].filter(Boolean) as { label: string; value: string; color: string }[]; + + const statColW = SIDEBAR_W / Math.max(statItems.length, 1); + statItems.forEach((s, i) => { + const sx = i * statColW + statColW / 2; + ctx.fillStyle = p.muted; + ctx.font = '12px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(s.label, sx, statsStartY); + ctx.fillStyle = s.color; + ctx.font = 'bold 30px system-ui, sans-serif'; + ctx.fillText(s.value, sx, statsStartY + 34); + }); + ctx.textAlign = 'left'; + + // ── Left: Since + npub ──────────────────────────────────────────────── + const sinceY = statsStartY + (statItems.length > 0 ? 56 : 0); + ctx.fillStyle = p.muted; + ctx.font = '13px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(`SINCE ${data.memberYear}`, SIDEBAR_W / 2, sinceY); + ctx.textAlign = 'left'; + + // ── Right zone ──────────────────────────────────────────────────────── + const RX = SIDEBAR_W + 28; // right content X start + const RMaxW = qrX - RX - 20; // max width before QR + let ry = 52; // right Y cursor + + // Brand tag (small, above name) + ctx.fillStyle = p.accent; + ctx.font = 'bold 14px system-ui, sans-serif'; + ctx.fillText('🛹 SKATESPOTS', RX, ry); + ry += 6; + ctx.fillStyle = p.muted; + ctx.font = '12px system-ui, sans-serif'; + ctx.fillText('SKATER LICENSE', RX, ry + 14); + ry += 30; + + // Name — big and bold + ctx.fillStyle = p.text; + ctx.font = 'bold 52px system-ui, sans-serif'; + fillTextFit(ctx, data.name.toUpperCase(), RX, ry + 48, RMaxW); + ry += 58; + + // nip05 + if (data.nip05) { + ctx.fillStyle = p.accent; + ctx.font = 'italic 20px Georgia, serif'; + fillTextFit(ctx, `@${data.nip05.split('@')[0]}`, RX, ry, RMaxW); + ry += 28; + } + + // Thin divider + ctx.strokeStyle = p.accent; + ctx.globalAlpha = 0.2; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(RX, ry); ctx.lineTo(RX + RMaxW, ry); ctx.stroke(); + ctx.globalAlpha = 1; + ry += 18; + + // Style chip + if (data.style) { + ctx.font = 'bold 16px system-ui, sans-serif'; + const chipTxt = data.style.toUpperCase(); + const chipW = ctx.measureText(chipTxt).width + 24; + roundRect(ctx, RX, ry - 18, chipW, 26, 6); + ctx.fillStyle = p.chipBg; + ctx.fill(); + ctx.strokeStyle = p.chipBorder; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.fillStyle = p.accent; + ctx.fillText(chipTxt, RX + 12, ry - 1); + ry += 22; + } + + // ── SKATE SHOP — distinctive color + italic font ────────────────────── + if (data.ridesFor) { + ctx.fillStyle = p.muted; + ctx.font = '11px system-ui, sans-serif'; + ctx.fillText('RIDES FOR', RX, ry); + ry += 4; + ctx.fillStyle = p.shopColor; + ctx.font = 'bold italic 22px Georgia, serif'; + fillTextFit(ctx, data.ridesFor, RX, ry + 20, RMaxW); + ry += 32; + } + + // ── SPONSORS — different color + lighter weight ──────────────────────── + if (data.sponsors) { + ctx.fillStyle = p.muted; + ctx.font = '11px system-ui, sans-serif'; + ctx.fillText('SPONSORS', RX, ry); + ry += 4; + ctx.fillStyle = p.sponsorColor; + ctx.font = '19px system-ui, sans-serif'; + fillTextFit(ctx, data.sponsors, RX, ry + 18, RMaxW); + ry += 30; + } + + // Thin divider before skills + if (data.skills.length > 0 || data.receivedBadges.length > 0) { + ctx.strokeStyle = p.accent; + ctx.globalAlpha = 0.15; + ctx.lineWidth = 1; + const divY = Math.max(ry + 4, CARD_H - 130); + ctx.beginPath(); ctx.moveTo(RX, divY); ctx.lineTo(RX + RMaxW, divY); ctx.stroke(); + ctx.globalAlpha = 1; + + // ── Skill chips ──────────────────────────────────────────────────── + const skillsToShow = [ + ...data.skills.slice(0, 5).map(id => { + const s = SKILL_OPTIONS.find(o => o.id === id); + return s ? { label: s.label, gold: false } : null; + }).filter(Boolean), + ...data.receivedBadges.slice(0, 3).map(b => { + const s = SKILL_OPTIONS.find(o => o.id === b.skill); + return s ? { label: `★ ${s.label}`, gold: true } : null; + }).filter(Boolean), + ] as { label: string; gold: boolean }[]; + + let chipX = RX; + let chipY = divY + 14; + ctx.font = 'bold 14px system-ui, sans-serif'; + skillsToShow.forEach(chip => { + const chipW = ctx.measureText(chip.label).width + 18; + if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; } + roundRect(ctx, chipX, chipY, chipW, 22, 5); + ctx.fillStyle = chip.gold ? 'rgba(255,215,0,0.18)' : p.chipBg; + ctx.fill(); + ctx.strokeStyle = chip.gold ? 'rgba(255,215,0,0.5)' : p.chipBorder; + ctx.lineWidth = 0.8; + ctx.stroke(); + ctx.fillStyle = chip.gold ? '#ffd700' : p.accent; + ctx.fillText(chip.label, chipX + 9, chipY + 16); + chipX += chipW + 6; + }); + } + + // ── Full npub — bottom of right zone ────────────────────────────────── + const npubY = CARD_H - 44; + ctx.fillStyle = p.muted; + ctx.font = '12px monospace'; + ctx.fillText(data.npub.slice(0, 44), RX, npubY); + ctx.fillText(data.npub.slice(44), RX, npubY + 16); + + } else { + // ═══════════════════════════════════════════════════════════════════════ + // PARK ACCESS LAYOUT + // ═══════════════════════════════════════════════════════════════════════ + + // ── Left: Avatar ───────────────────────────────────────────────────── + const avSize = 176; + const avX = (SIDEBAR_W - avSize) / 2; + const avY = 22; + roundRect(ctx, avX, avY, avSize, avSize, 14); + ctx.fillStyle = p.chipBg; + ctx.fill(); + if (avatarImg) { + ctx.save(); + roundRect(ctx, avX, avY, avSize, avSize, 14); + ctx.clip(); + ctx.drawImage(avatarImg, avX, avY, avSize, avSize); + ctx.restore(); + } + + // Access level chip under avatar + const alTxt = (data.accessLevel || 'FULL ACCESS').toUpperCase(); + ctx.font = 'bold 16px system-ui, sans-serif'; + const alW = ctx.measureText(alTxt).width + 24; + const alX = (SIDEBAR_W - alW) / 2; + const alY = avY + avSize + 16; + roundRect(ctx, alX, alY, alW, 30, 7); + ctx.fillStyle = p.accent; + ctx.fill(); + ctx.fillStyle = p.bg1; + ctx.fillText(alTxt, alX + 12, alY + 21); + + // valid thru under access level + if (data.validThru) { + ctx.fillStyle = p.muted; + ctx.font = '12px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('VALID THRU', SIDEBAR_W / 2, alY + 52); + ctx.fillStyle = p.accent; + ctx.font = 'bold 20px system-ui, sans-serif'; + ctx.fillText(data.validThru, SIDEBAR_W / 2, alY + 76); + ctx.textAlign = 'left'; + } + + // ── Right zone ──────────────────────────────────────────────────────── + const RX = SIDEBAR_W + 28; + const RMaxW = qrX - RX - 20; + let ry = 52; + + // Park name + label + ctx.fillStyle = p.accent; + ctx.font = 'bold 14px system-ui, sans-serif'; + ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, RX, ry); + ry += 6; + ctx.fillStyle = p.muted; + ctx.font = '12px system-ui, sans-serif'; + ctx.fillText('PARK ACCESS CARD', RX, ry + 14); + ry += 34; + + // Member name + ctx.fillStyle = p.text; + ctx.font = 'bold 50px system-ui, sans-serif'; + fillTextFit(ctx, (data.recipientName || 'MEMBER').toUpperCase(), RX, ry + 46, RMaxW); + ry += 60; + + // Divider + ctx.strokeStyle = p.accent; + ctx.globalAlpha = 0.2; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(RX, ry); ctx.lineTo(RX + RMaxW, ry); ctx.stroke(); + ctx.globalAlpha = 1; + ry += 22; + + // Issued by + ctx.fillStyle = p.muted; + ctx.font = '13px system-ui, sans-serif'; + ctx.fillText('ISSUED BY PARK OWNER · NOSTR-SIGNED', RX, ry); + ry += 26; + + // Full npub + ctx.fillStyle = p.accent; + ctx.font = '14px monospace'; + ctx.fillText(data.npub.slice(0, 44), RX, ry); + ry += 20; + ctx.fillText(data.npub.slice(44), RX, ry); + } + + // ─── 6. Bottom bar ─────────────────────────────────────────────────────── + ctx.fillStyle = p.muted; + ctx.globalAlpha = 0.18; + ctx.fillRect(0, CARD_H - 36, CARD_W, 36); + ctx.globalAlpha = 1; + + ctx.fillStyle = p.muted; + ctx.font = '13px system-ui, sans-serif'; + ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', 32, CARD_H - 13); + ctx.textAlign = 'right'; + ctx.fillText(data.memberYear, CARD_W - 32, CARD_H - 13); + ctx.textAlign = 'left'; + + return canvas.toDataURL('image/png'); +} ctx.fillStyle = p.muted; ctx.font = '15px system-ui, sans-serif'; ctx.textAlign = 'center'; @@ -426,34 +728,6 @@ async function drawCardToCanvas(data: CanvasCardData): Promise { } } - // ── Bottom divider line ────────────────────────────────────────────────── - ctx.strokeStyle = p.muted; - ctx.globalAlpha = 0.2; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(PAD, CARD_H - 42); - ctx.lineTo(CARD_W - PAD, CARD_H - 42); - ctx.stroke(); - ctx.globalAlpha = 1; - - // ── Bottom bar ────────────────────────────────────────────────────────── - ctx.fillStyle = p.muted; - ctx.font = '14px system-ui, sans-serif'; - ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', PAD, CARD_H - 18); - ctx.textAlign = 'right'; - // Show year issued - ctx.fillText(data.memberYear, CARD_W - PAD, CARD_H - 18); - ctx.textAlign = 'left'; - - return canvas.toDataURL('image/png'); -} - -import { - Printer, Download, Copy, Palette, Check, Building2, - User, Shield, Award, Zap, - ChevronDown, ChevronUp, Info, Loader2, -} from 'lucide-react'; - // ─── Nostr kinds ────────────────────────────────────────────────────────────── export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider export const SKILL_BADGE_KIND = 1293; // Witness awards skill badge to rider @@ -1053,7 +1327,6 @@ export default function RiderCardPage() { const [theme, setTheme] = useState('thug'); const [holographic, setHolographic] = useState(true); const [showAwardDialog, setShowAwardDialog] = useState(false); - const [activeTab, setActiveTab] = useState<'card' | 'badges'>('card'); // Rider fields const [style, setStyle] = useState('Street'); @@ -1212,6 +1485,38 @@ export default function RiderCardPage() { setTimeout(() => setCopied(false), 2000); }, [npub]); + // ── Share card digitally ────────────────────────────────────────────────── + const handleShare = useCallback(async () => { + const dataUrl = await renderCardToImage(); + if (!dataUrl) { + toast({ title: 'Could not render card', variant: 'destructive' }); + return; + } + + // Convert data URL to File for Web Share API + const res = await fetch(dataUrl); + const blob = await res.blob(); + const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' }); + + if (navigator.share && navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ + title: `${cardData.name}'s SkateSpots Card`, + text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/${cardData.npub}`, + files: [file], + }); + return; + } catch { + // user cancelled or share failed — fall through to copy + } + } + + // Fallback: copy profile link to clipboard + const link = `https://skate-spots.org/${cardData.npub}`; + await navigator.clipboard.writeText(link); + toast({ title: '🔗 Link copied!', description: 'Share it anywhere — the link opens your card profile.' }); + }, [renderCardToImage, cardData.name, cardData.npub, toast]); + const handlePublishParkCard = useCallback(() => { if (!user) return; if (!recipientPubkey) { @@ -1270,99 +1575,74 @@ export default function RiderCardPage() {

- {/* Inner tab nav */} -
- - - -
- - {/* ── Card tab ─────────────────────────────────────────────────── */} - {activeTab === 'card' && ( -
+
{/* Left: Card preview */} -
+
- {/* Actions */} -
+ {/* Primary actions */} +
+ +
+ + {/* Secondary actions */} +
+ {cardType === 'park' && ( )}
-
-

- 🖨️ Print Card opens a print window with the card as an image — backgrounds guaranteed to show. -

-

- 💾 Save PNG to share digitally, use in iMessage, WhatsApp, or take to a print shop. -

-
+

+ Share uses your device's share sheet (WhatsApp, iMessage, etc.) or copies the link. Save PNG downloads for print shops. +

{/* Right: Controls */} @@ -1612,43 +1892,7 @@ export default function RiderCardPage() {
- )} - - {/* ── Badges tab ────────────────────────────────────────────────── */} - {activeTab === 'badges' && ( -
-
-
-

Your Witnessed Badges

-

- Badges signed by people who saw you skate. Cryptographically unforgeable. -

-
- -
- - - -
-

🏅 How badges work

-
    -
  • Anyone can award you a badge when they witness your tricks
  • -
  • Badges are signed Nostr events — they can't be faked
  • -
  • The more trusted the witness (NIP-05 verified, known in the community), the more weight the badge carries
  • -
  • Gold ⭐ badges on your card show skills others have witnessed — different from your self-claimed skills
  • -
  • Ask friends and filmers to award you badges at spots you've skated
  • -
-
-
- )} -
+
{/* Award badge dialog */}