/** * RiderCardPage — /card * * Card types: * 1. Rider License — personal card auto-filled from Nostr profile * 2. Park Access Card — issued by a park owner to a rider (signed Nostr event) * * Badge System (kind 1293): * - Anyone can award a skill badge to any rider * - Badges appear on the rider's card with the witness's name * * 6 visual themes: THUG · LIME · CHROME · WOOD · MIDNIGHT · SUNSET * Print-ready: credit-card size (85.6×54mm), @media print isolates card */ import { useState, useRef, useCallback } from 'react'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; // QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API // access at module load time (crashes the preview environment) import { Layout } from '@/components/Layout'; import { LoginArea } from '@/components/auth/LoginArea'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useRiderStats } from '@/hooks/useRiderStats'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { useNostr } from '@/hooks/useNostr'; import { useQuery } from '@tanstack/react-query'; import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, Shield, ChevronUp, ChevronDown, } from 'lucide-react'; // ─── Card canvas renderer ───────────────────────────────────────────────────── // Draws the card entirely via Canvas 2D API — 100% reliable export, // no CSS capture, no scaling issues, no background-stripping. // Card is 856×540px (10× credit-card mm, high-res for printing). const CARD_W = 856; const CARD_H = 540; interface CanvasCardData { type: 'rider' | 'park'; themeId: string; name: string; nip05?: string; avatarUrl?: string; pubkey: string; npub: string; style: string; skills: string[]; sponsors: string; ridesFor: string; showSpots: boolean; showZaps: boolean; showEvents: boolean; spotsAdded: number; zapsReceived: number; eventsCreated: number; memberYear: string; receivedBadges: BadgeAward[]; // Park issuerName: string; recipientName: string; accessLevel: string; validThru: string; } // Theme palette for canvas drawing const THEME_PALETTE: Record = { 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 { return new Promise((resolve) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => resolve(null); // Only proxy actual HTTP URLs — data: URIs must never go through proxy if (url.startsWith('data:') || url.startsWith('blob:')) { img.src = url; } else { img.src = `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}`; } setTimeout(() => resolve(null), 5000); }); } // Draw text that wraps or truncates to fit maxWidth, returns lines drawn function fillTextFit(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number): void { let t = text; while (ctx.measureText(t).width > maxWidth && t.length > 2) { t = t.slice(0, -1); } if (t.length < text.length) t = t.slice(0, -1) + '…'; ctx.fillText(t, x, y); } // Draw a labeled row: small muted label above, value below function drawLabeledRow( ctx: CanvasRenderingContext2D, label: string, value: string, x: number, y: number, maxW: number, labelColor: string, valueColor: string, labelSize: number, valueSize: number, bold = false, ) { ctx.fillStyle = labelColor; ctx.font = `${labelSize}px system-ui, sans-serif`; ctx.fillText(label.toUpperCase(), x, y); ctx.fillStyle = valueColor; ctx.font = `${bold ? 'bold ' : ''}${valueSize}px system-ui, sans-serif`; fillTextFit(ctx, value, x, y + valueSize + 2, maxW); } function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } async function drawCardToCanvas(data: CanvasCardData): Promise { const canvas = document.createElement('canvas'); canvas.width = CARD_W; canvas.height = CARD_H; const ctx = canvas.getContext('2d')!; const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug; // ─── 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 = bgGrad; ctx.fill(); // ─── 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, 10); // ─── 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 { // Dynamic import — avoids canvas API access at module load time const QRCode = (await import('qrcode')).default; const qrDataUrl = await QRCode.toDataURL(qrUrl, { width: qrSize * 2, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' }, }); qrImg = await loadImage(qrDataUrl); // data: URI — no proxy } catch { /* skip */ } if (qrImg) { 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'); } // ─── 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 // ─── Themes ─────────────────────────────────────────────────────────────────── const THEMES = [ { id: 'thug', label: 'THUG', emoji: '🔴', desc: 'Black + Neon Red' }, { id: 'lime', label: 'LIME', emoji: '🟢', desc: 'Black + Neon Lime' }, { id: 'chrome', label: 'CHROME', emoji: '⚪', desc: 'Steel & Silver' }, { id: 'wood', label: 'WOOD', emoji: '🟤', desc: 'Woodgrain Classic' }, { id: 'midnight', label: 'MIDNIGHT', emoji: '🔵', desc: 'Deep Blue' }, { id: 'sunset', label: 'SUNSET', emoji: '🟠', desc: 'Orange & Pink' }, ] as const; type ThemeId = typeof THEMES[number]['id']; // ─── Skill badges ───────────────────────────────────────────────────────────── const SKILL_OPTIONS = [ { id: 'kickflip', label: 'Kickflip', icon: '🛹' }, { id: 'heelflip', label: 'Heelflip', icon: '👟' }, { id: 'switch', label: 'Switch', icon: '🔄' }, { id: 'nollie', label: 'Nollie', icon: '⬆️' }, { id: 'fakie', label: 'Fakie', icon: '↩️' }, { id: 'grind', label: 'Grinds', icon: '⚡' }, { id: 'slide', label: 'Slides', icon: '➡️' }, { id: 'manual', label: 'Manuals', icon: '🎯' }, { id: 'vert', label: 'Vert', icon: '🏄' }, { id: 'bowl', label: 'Bowl', icon: '🥣' }, { id: 'street', label: 'Street', icon: '🏙️' }, { id: 'park', label: 'Park', icon: '🌿' }, { id: 'film', label: 'Filmer', icon: '🎥' }, { id: 'diy', label: 'DIY Builder', icon: '🔨' }, ] as const; const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const; // ─── Helpers ────────────────────────────────────────────────────────────────── function shortNpub(pubkey: string) { if (!pubkey) return '—'; const npub = nip19.npubEncode(pubkey); return `${npub.slice(0, 12)}…${npub.slice(-6)}`; } 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; witnessPubkey: string; skill: string; spotName?: string; timestamp: number; } // ─── Card data shape ────────────────────────────────────────────────────────── interface CardData { type: 'rider' | 'park'; theme: ThemeId; holographic: boolean; // Rider name: string; nip05?: string; avatar?: string; pubkey: string; npub: string; style: string; skills: string[]; sponsors: string; ridesFor: string; showSpots: boolean; showZaps: boolean; showEvents: boolean; spotsAdded: number; zapsReceived: number; eventsCreated: number; memberYear: string; receivedBadges: BadgeAward[]; // Park issuerName: string; recipientName: string; accessLevel: string; validThru: string; } // ─── Hook: fetch skill badges received by a pubkey ──────────────────────────── function useReceivedBadges(pubkey: string) { const { nostr } = useNostr(); return useQuery({ queryKey: ['skill-badges', pubkey], queryFn: async (c) => { if (!pubkey) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); const events = await nostr.query( [{ kinds: [SKILL_BADGE_KIND], '#p': [pubkey], limit: 50 }], { signal }, ); return events.filter(e => e.tags.some(([k]) => k === 'p') && e.tags.some(([k]) => k === 'skill') ); }, enabled: !!pubkey, }); } // ─── Hook: fetch author name for a pubkey ───────────────────────────────────── function useWitnessName(pubkey: string) { const author = useAuthor(pubkey); return author.data?.metadata?.name ?? genUserName(pubkey); } // ─── 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 }) { const witnessedBadges = d.receivedBadges.slice(0, 2); const qrUrl = verifyUrl(d.pubkey); return ( // absolute inset — fills the card exactly
{/* ── Row 1: header (26px) ── */}
🛹 SkateSpots
Skater License
npub
{shortNpub(d.pubkey)}
Since {d.memberYear}
{/* ── Row 2: main body (118px) ── */}
{/* Avatar */}
{d.avatar ? ( {d.name} ) : (
🛹
)}
{/* Stats under avatar */}
{d.showSpots && (
Spots {d.spotsAdded}
)} {d.showZaps && (
⚡ {d.zapsReceived}
)} {d.showEvents && (
Evts {d.eventsCreated}
)}
{/* Identity block */}
{d.name}
{d.nip05 && (
@{d.nip05.split('@')[0]}
)} {d.style && ( {d.style} )} {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; })}
{/* QR placeholder — real QR renders in exported PNG */}
{Array.from({ length: 25 }).map((_, i) => (
))}
Scan to verify
{/* ── Bottom bar: npub + website (absolute at bottom) ── */}
skate-spots.org · Powered by Nostr {shortNpub(d.pubkey)}
); } // ─── Card visual: Park Access front ────────────────────────────────────────── function ParkFront({ d }: { d: CardData }) { const qrUrl = verifyUrl(d.pubkey); return (
{/* Row 1: header */}
🏟️ {d.issuerName || 'Skatepark'}
Access Pass
{d.accessLevel || 'Full Access'}
{/* 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}
)}
{/* QR placeholder */}
{Array.from({ length: 25 }).map((_, i) => (
))}
Scan to verify
{/* Bottom bar */}
skate-spots.org · Powered by Nostr
Nostr-verified
); } // ─── Main card wrapper ──────────────────────────────────────────────────────── function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { const themeClass = `card-theme-${d.theme}`; const holoClass = d.holographic ? 'rider-card-holo' : ''; return (
{/* Top color stripe */}
{d.type === 'rider' && } {d.type === 'park' && }
); } // ─── Award Badge Dialog ─────────────────────────────────────────────────────── interface AwardBadgeDialogProps { open: boolean; onClose: () => void; issuerPubkey: string; } function AwardBadgeDialog({ open, onClose, issuerPubkey }: AwardBadgeDialogProps) { const { mutate: publishEvent, isPending } = useNostrPublish(); const { toast } = useToast(); const [recipientNpub, setRecipientNpub] = useState(''); const [selectedSkills, setSelectedSkills] = useState([]); const [spotName, setSpotName] = useState(''); const toggleSkill = (id: string) => setSelectedSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id] ); const handleAward = () => { if (!recipientNpub.trim() || selectedSkills.length === 0) { toast({ title: 'Fill in rider npub + at least one skill', variant: 'destructive' }); return; } let recipientHex = ''; try { const decoded = nip19.decode(recipientNpub.trim()); if (decoded.type !== 'npub') throw new Error('not npub'); recipientHex = decoded.data; } catch { toast({ title: 'Invalid npub', description: 'Paste a valid npub1… address', variant: 'destructive' }); return; } const tags: string[][] = [ ['p', recipientHex], ['t', 'skill-badge'], ['alt', `Skill badge awarded: ${selectedSkills.join(', ')}`], ...selectedSkills.map(s => ['skill', s]), ]; if (spotName.trim()) tags.push(['spot', spotName.trim()]); publishEvent( { kind: SKILL_BADGE_KIND, content: '', tags }, { onSuccess: () => { toast({ title: '🏅 Badge awarded!', description: `${selectedSkills.join(', ')} sent to rider.` }); setRecipientNpub(''); setSelectedSkills([]); setSpotName(''); onClose(); }, onError: (e) => toast({ title: 'Failed', description: String(e), variant: 'destructive' }), }, ); }; return ( !v && onClose()}> Award a Skill Badge You saw it happen. Sign the proof on Nostr — it becomes part of their permanent record.
{/* Recipient */}
setRecipientNpub(e.target.value)} placeholder="npub1…" className="bg-black/50 border-gray-700 font-mono text-xs" />
{/* Skills */}
{SKILL_OPTIONS.map(s => ( ))}
{/* Spot name */}
setSpotName(e.target.value)} placeholder="e.g. MACBA, The Berrics, Burnside…" className="bg-black/50 border-gray-700 text-sm" />
🔐 Signed proof.{' '} This badge is a signed message on the Nostr network — cryptographically tied to your key. The rider can show it with your name as witness, and anyone can verify it's real.
); } // ─── Received badges panel ──────────────────────────────────────────────────── function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) { const { data: badgeEvents = [] } = useReceivedBadges(pubkey); const [expanded, setExpanded] = useState(false); if (badgeEvents.length === 0) { return (
🏅

No badges yet. Skate in front of someone and ask them to witness your tricks!

); } const show = expanded ? badgeEvents : badgeEvents.slice(0, 3); return (
{show.map((event) => ( ))} {badgeEvents.length > 3 && ( )}
); } function BadgeEventRow({ event }: { event: NostrEvent }) { const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v); const spot = event.tags.find(([k]) => k === 'spot')?.[1]; const witnessName = useWitnessName(event.pubkey); const witnessAuthor = useAuthor(event.pubkey); const witnessAvatar = witnessAuthor.data?.metadata?.picture; return (
{witnessName.slice(0, 2).toUpperCase()}
{skills.map(sk => { const s = SKILL_OPTIONS.find(o => o.id === sk); return s ? ( {s.icon} {s.label} ) : null; })}

Witnessed by {witnessName} {spot && <> at {spot}}

{new Date(event.created_at * 1000).toLocaleDateString()}
); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function RiderCardPage() { useSeoMeta({ title: 'Skater Card — SkateSpots', description: 'Create your printable SkateSpots Skater License or Park Access Card.', }); const { user } = useCurrentUser(); const author = useAuthor(user?.pubkey ?? ''); const metadata = author.data?.metadata; const { data: stats } = useRiderStats(user?.pubkey); const { mutate: publishEvent } = useNostrPublish(); const { toast } = useToast(); const printRef = useRef(null); const displayName = metadata?.name ?? genUserName(user?.pubkey ?? ''); const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : ''; // Received badges for the logged-in user const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? ''); // Parse badge events into BadgeAward objects const receivedBadges: BadgeAward[] = receivedBadgeEvents.flatMap(event => { const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v); const spot = event.tags.find(([k]) => k === 'spot')?.[1]; return skills.map(skill => ({ skill, witnessName: genUserName(event.pubkey), // simplified for card visual witnessPubkey: event.pubkey, spotName: spot, timestamp: event.created_at, })); }); // ── Form state ────────────────────────────────────────────────────────────── const [cardType, setCardType] = useState<'rider' | 'park'>('rider'); const [theme, setTheme] = useState('thug'); const [holographic, setHolographic] = useState(true); const [showAwardDialog, setShowAwardDialog] = useState(false); // Rider fields const [style, setStyle] = useState('Street'); const [skills, setSkills] = useState([]); const [sponsors, setSponsors] = useState(''); const [ridesFor, setRidesFor] = useState(''); const [showSpots, setShowSpots] = useState(true); const [showZaps, setShowZaps] = useState(true); const [showEvents, setShowEvents] = useState(true); // Park fields const [issuerName, setIssuerName] = useState(''); const [recipientName, setRecipientName] = useState(''); const [recipientNpub, setRecipientNpub] = useState(''); const [accessLevel, setAccessLevel] = useState('Full Access'); const [validThru, setValidThru] = useState(''); const [copied, setCopied] = useState(false); const [rendering, setRendering] = useState(false); // ── Parse recipientNpub → pubkey ───────────────────────────────────────── const recipientPubkey = (() => { if (!recipientNpub.trim()) return user?.pubkey ?? ''; try { const d = nip19.decode(recipientNpub.trim()); return d.type === 'npub' ? d.data : (user?.pubkey ?? ''); } catch { return user?.pubkey ?? ''; } })(); // ── Computed card data ─────────────────────────────────────────────────────── const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey; const cardData: CardData = { type: cardType, theme, holographic, name: cardType === 'rider' ? displayName : recipientName, nip05: metadata?.nip05, avatar: metadata?.picture, pubkey: cardPubkey, npub: cardPubkey ? fullNpub(cardPubkey) : '', style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, spotsAdded: stats?.spotsAdded ?? 0, zapsReceived: stats?.zapsReceived ?? 0, eventsCreated: stats?.eventsCreated ?? 0, memberYear: metadata?.created_at ? memberSince(metadata.created_at as number) : new Date().getFullYear().toString(), receivedBadges, issuerName, recipientName, accessLevel, validThru, }; // ── Render card via Canvas 2D API → returns a PNG data URL ────────────── const renderCardToImage = useCallback(async (): Promise => { setRendering(true); try { const dataUrl = await drawCardToCanvas({ type: cardData.type, themeId: cardData.theme, name: cardData.name, nip05: cardData.nip05, avatarUrl: cardData.avatar, pubkey: cardData.pubkey, npub: cardData.npub, style: cardData.style, skills: cardData.skills, sponsors: cardData.sponsors, ridesFor: cardData.ridesFor, showSpots: cardData.showSpots, showZaps: cardData.showZaps, showEvents: cardData.showEvents, spotsAdded: cardData.spotsAdded, zapsReceived: cardData.zapsReceived, eventsCreated: cardData.eventsCreated, memberYear: cardData.memberYear, receivedBadges: cardData.receivedBadges, issuerName: cardData.issuerName, recipientName: cardData.recipientName, accessLevel: cardData.accessLevel, validThru: cardData.validThru, }); return dataUrl; } catch (err) { console.error('Card render failed:', err); return null; } finally { setRendering(false); } }, [cardData]); // ── Print: render to image, open print window with the image ───────────── const handlePrint = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: 'Could not render card', description: 'Try again in a moment.', variant: 'destructive' }); return; } // 85.6mm × 54mm in points (1mm ≈ 2.835pt) const w = Math.round(85.6 * 2.835); const h = Math.round(54 * 2.835); const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`); if (!win) { toast({ title: 'Popup blocked', description: 'Allow popups for this site then try again.', variant: 'destructive' }); return; } win.document.write(` SkateSpots Rider Card `); win.document.close(); }, [renderCardToImage, toast]); // ── Download card as PNG ────────────────────────────────────────────────── const handleDownload = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; } const a = document.createElement('a'); a.href = dataUrl; a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`; a.click(); }, [renderCardToImage, cardData.licenseId, toast]); const handleCopyNpub = useCallback(() => { navigator.clipboard.writeText(npub); setCopied(true); 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) { toast({ title: "Enter the rider's npub", variant: 'destructive' }); return; } const tags: string[][] = [ ['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'], ['issuer', issuerName], ['access-level', accessLevel], ['theme', theme], ]; if (validThru) tags.push(['valid-thru', validThru]); publishEvent( { kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { onSuccess: () => toast({ title: '✅ Park Access Card published!', description: 'The rider can now verify it.' }), onError: (e) => toast({ title: 'Publish failed', description: String(e), variant: 'destructive' }), }, ); }, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast]); const toggleSkill = (id: string) => setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]); // ── Render: logged out ──────────────────────────────────────────────────── if (!user) { return (
🛹

Rider Card

Log in to generate your personal Rider Card.

); } return (
{/* Page header */}

SKATE CARDS

Print-ready credit-card size (85.6 × 54 mm). QR-verified via Nostr.

{/* Left: Card preview */}
{/* Primary actions */}
{/* Secondary actions */}
{cardType === 'park' && ( )}

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

{/* Right: Controls */}
{/* Card type */}
{([ { id: 'rider', label: 'Skater License', icon: User }, { id: 'park', label: 'Park Access', icon: Building2 }, ] as const).map(({ id, label, icon: Icon }) => ( ))}
{/* Park access note */} {cardType === 'park' && (
Park owners only.{' '} Park Access Cards should only be issued by the park's own Nostr key — the same key that published the park listing on SkateSpots.
How verification works:{' '} The card is a signed message on the Nostr network (like a public post only you could have written). Anyone can scan the QR, look up the event on any Nostr relay, and see which key signed it — if that key matches the park's registered identity, the card is genuine. No central authority needed.
Self-issued cards for private parks will show a mismatch warning — the park owner's key won't match.
)} {/* Theme picker */}
{THEMES.map(t => ( ))}
{/* Holographic toggle */}
✨ Holographic Shimmer
Animated foil effect on screen
{/* ── RIDER fields ─────────────────────────────────────────── */} {cardType === 'rider' && (
{/* Style */}
{/* Rides For shop */}
setRidesFor(e.target.value)} className="bg-black/50 border-gray-700 text-sm" />

The local shop you represent. Different from sponsors.

{/* Sponsors */}
setSponsors(e.target.value)} className="bg-black/50 border-gray-700 text-sm" />
{/* Stats toggles */}
{[ { label: `Spots (${stats?.spotsAdded ?? 0})`, state: showSpots, set: setShowSpots }, { label: `Zaps (${stats?.zapsReceived ?? 0})`, state: showZaps, set: setShowZaps }, { label: `Events (${stats?.eventsCreated ?? 0})`, state: showEvents, set: setShowEvents }, ].map(({ label, state, set }) => ( ))}
{/* Skill badges (self-selected) */}
{SKILL_OPTIONS.map(s => ( ))}

⭐ Gold badges on the card are witnessed by others — check the Badges tab.

)} {/* ── PARK fields ──────────────────────────────────────────── */} {cardType === 'park' && (
setIssuerName(e.target.value)} placeholder="e.g. The Berrics, FDR Skatepark…" className="bg-black/50 border-gray-700" />
setRecipientName(e.target.value)} placeholder="Full name on card" className="bg-black/50 border-gray-700" />
setRecipientNpub(e.target.value)} placeholder="npub1… — their Nostr identity ties the card to them" className="bg-black/50 border-gray-700 font-mono text-xs" />

Leave blank to issue to yourself. The card is signed by your key as the park owner.

setValidThru(e.target.value)} placeholder="e.g. 2026-12-31" className="bg-black/50 border-gray-700" />
🔐 How the park verifies it's real
When you publish this card, it's signed with your Nostr private key and broadcast to relays. The park's staff can scan the QR on any printed card and instantly see:
  • Who issued it (your npub)
  • Who it was issued to (the rider's npub)
  • The access level and expiry
  • The exact timestamp it was signed
A rider can't fake this card — they'd need your private key to forge your signature.
)}
{/* Award badge dialog */} setShowAwardDialog(false)} issuerPubkey={user.pubkey} />
); }