/** * 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, useMemo } 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 { 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 { 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, User, Building2 } 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); 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 }) { 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' && }
); } // ─── 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) : ''; const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? ''); 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), witnessPubkey: event.pubkey, spotName: spot, timestamp: event.created_at, })); }); // ── Page tab: 'card' | 'badges' | 'award' ──────────────────────────────── const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card'); // ── Card state ──────────────────────────────────────────────────────────── const [cardType, setCardType] = useState<'rider' | 'park'>('rider'); const [theme, setTheme] = useState('thug'); const [holographic, setHolographic] = useState(true); // 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); 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 ?? ''; } })(); const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey; const cardData: CardData = useMemo( () => ({ 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, }), [ cardType, theme, holographic, displayName, recipientName, metadata, cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, ], ); const renderCardToImage = useCallback(async (): Promise => { setRendering(true); try { return 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, }); } catch (err) { console.error('Card render failed:', err); return null; } finally { setRendering(false); } }, [cardData]); const handlePrint = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; } 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.', variant: 'destructive' }); return; } win.document.write(`SkateSpots Rider Cardwindow.onload=function(){window.focus();window.print()}`); win.document.close(); }, [renderCardToImage, toast]); 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.npub, toast]); const handleCopyNpub = useCallback(() => { navigator.clipboard.writeText(npub); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [npub]); const handleShare = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; } 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 { /* fall through */ } } await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`); toast({ title: '🔗 Link copied!' }); }, [renderCardToImage, cardData.name, cardData.npub, toast]); const handlePublishParkCard = useCallback(() => { if (!user || !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!' }), 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]); // ── Logged out ──────────────────────────────────────────────────────────── if (!user) { return ( <>
{/* Hero */}
🛹

RIDER CARD

Your verifiable skater identity. Nostr-signed, print-ready, impossible to fake.

Earn witnessed skill badges from other skaters. Get park access passes. Share your card anywhere.

); } return ( <>
{/* ── Page header ── */}

RIDER CARD

Nostr-signed · Print-ready · QR-verified

{/* ── Top tabs: My Card / My Badges / Award Badge ── */}
{([ { id: 'card', label: '🪪 My Card' }, { id: 'badges', label: `🏅 Badges${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` }, { id: 'award', label: '⭐ Award' }, ] as const).map(tab => ( ))}
{/* ══════════════════════════════════════════════ TAB: MY CARD ══════════════════════════════════════════════ */} {pageTab === 'card' && (
{/* ── Hero card preview ── */}
{/* Stage */}
{/* Subtle glow behind card */}
{/* Primary CTA */} {/* Secondary actions row */}
{/* ── Card type toggle ── */}

Card Type

{([ { id: 'rider', label: 'Skater License', sub: 'Your personal rider identity', icon: User }, { id: 'park', label: 'Park Access', sub: 'Issue passes as a park owner', icon: Building2 }, ] as const).map(({ id, label, sub, icon: Icon }) => ( ))}
{/* ── Theme picker ── */}

Theme

{THEMES.map(t => ( ))}
{/* ── Holographic toggle ── */}
✨ Holographic Shimmer
Animated foil effect
{/* ── RIDER fields ── */} {cardType === 'rider' && (

Card Details

{/* Style */}
{/* Rides For + Sponsors — side by side */}
setRidesFor(e.target.value)} className="bg-black/50 border-white/10 text-sm h-11" />
setSponsors(e.target.value)} className="bg-black/50 border-white/10 text-sm h-11" />
{/* Stats toggles */}
{[ { label: 'Spots', value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots }, { label: 'Zaps ⚡', value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps }, { label: 'Events', value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents }, ].map(({ label, value, state, set }) => ( ))}
{/* Skills */}
{skills.length}/6 selected
{SKILL_OPTIONS.map(s => ( ))}

⭐ Gold badges on your card come from other skaters witnessing you — see the .

)} {/* ── PARK fields ── */} {cardType === 'park' && (
Park owners only.{' '} Issue this using the same Nostr key that published your park listing. Anyone can scan the QR and verify the signature on-chain — no central authority needed.

Park Details

setIssuerName(e.target.value)} placeholder="The Berrics…" className="bg-black/50 border-white/10 h-11" />
setRecipientName(e.target.value)} placeholder="Name on card" className="bg-black/50 border-white/10 h-11" />
setRecipientNpub(e.target.value)} placeholder="npub1…" className="bg-black/50 border-white/10 font-mono text-xs h-11" />
setValidThru(e.target.value)} placeholder="2026-12-31" className="bg-black/50 border-white/10 h-11" />
)}
)} {/* ══════════════════════════════════════════════ TAB: MY BADGES ══════════════════════════════════════════════ */} {pageTab === 'badges' && (

These badges were witnessed and signed by other skaters on Nostr. They appear as gold ⭐ chips on your card.

Want to earn more? Skate in front of someone and ask them to witness your trick.

)} {/* ══════════════════════════════════════════════ TAB: AWARD BADGE (inline, no dialog) ══════════════════════════════════════════════ */} {pageTab === 'award' && (
You saw it happen
Sign the proof on Nostr. It becomes part of their permanent record — cryptographically tied to your key.
{/* Inline award form — the dialog was removed as dead code */} setPageTab('badges')} />
)}
); } // ─── Inline award form (used on the Award tab) ──────────────────────────────── function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) { 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: 'Add 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(); recipientHex = decoded.data; } catch { toast({ title: 'Invalid npub', 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.` }); setRecipientNpub(''); setSelectedSkills([]); setSpotName(''); onSuccess(); }, onError: (e) => toast({ title: 'Failed', description: String(e), variant: 'destructive' }), }); }; return (
setRecipientNpub(e.target.value)} placeholder="npub1…" className="bg-black/50 border-white/10 font-mono text-xs h-11" />
{SKILL_OPTIONS.map(s => ( ))}
setSpotName(e.target.value)} placeholder="MACBA, The Berrics, Burnside…" className="bg-black/50 border-white/10 text-sm h-11" />
); }