/** * 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, useEffect, 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 { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard'; import { THEMES, SKILL_OPTIONS, STYLES, fullNpub, memberSince, roleMeta, formatCardDate, type ThemeId } from '@/lib/riderCard'; 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 { useRiderCard, useRiderRoles } from '@/hooks/useRiderCard'; import { useUserCrews } from '@/hooks/useCrews'; import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateEvents } from '@/hooks/useSkateEvents'; import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; 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 { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; import { RIDER_CARD_KIND, RIDER_CARD_D, PARK_ACCESS_CARD_KIND, SKILL_BADGE_KIND, cardVerifyUrl, riderCardTags, type RiderCardConfig } from '@/lib/riderCard'; import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket } 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' | 'crew' | 'event'; 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[]; /** When set, the printed QR opens the verify page for this card. */ verifyUrl?: string; local?: string; crews?: string[]; roles?: string[]; crew?: CardData['crew']; event?: CardData['event']; // 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) ──────────── // The QR opens the card's verify page when published; otherwise the profile. const qrUrl = data.verifyUrl || `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; } // Local + crew crests (identity facets) if (data.local) { ctx.fillStyle = p.accent; ctx.font = 'bold 17px system-ui, sans-serif'; fillTextFit(ctx, `📍 ${data.local.toUpperCase()}`, RX, ry, RMaxW); ry += 22; } if (data.crews && data.crews.length > 0) { ctx.fillStyle = p.muted; ctx.font = '14px system-ui, sans-serif'; fillTextFit(ctx, `⛺ ${data.crews.slice(0, 3).join(' · ')}`, RX, ry, RMaxW); ry += 20; } 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 || (data.roles?.length ?? 0) > 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.roles ?? []).slice(0, 3).map(r => { const m = roleMeta(r); return m ? { label: `${m.emoji} ${m.label}`, gold: false } : null; }).filter(Boolean), ...data.skills.slice(0, 4).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 if (data.type === 'park') { // ═══════════════════════════════════════════════════════════════════════ // 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); } else if (data.type === 'crew') { // ═══════════════════════════════════════════════════════════════════════ // CREW LAYOUT // ═══════════════════════════════════════════════════════════════════════ const c = data.crew; 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.fillStyle = p.accent; ctx.font = '80px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('🏕️', SIDEBAR_W / 2, avY + 118); ctx.textAlign = 'left'; } // Left: members + spots ctx.textAlign = 'center'; ctx.fillStyle = p.muted; ctx.font = '12px system-ui, sans-serif'; ctx.fillText('MEMBERS', SIDEBAR_W / 4 + 8, avY + avSize + 32); ctx.fillText('SPOTS', (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 32); ctx.fillStyle = p.accent; ctx.font = 'bold 34px system-ui, sans-serif'; ctx.fillText(String(c?.members ?? 0), SIDEBAR_W / 4 + 8, avY + avSize + 68); ctx.fillStyle = p.accent2; ctx.fillText(String(c?.spots ?? 0), (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 68); ctx.textAlign = 'left'; // Right zone const RX = SIDEBAR_W + 28; const RMaxW = qrX - RX - 20; let ry = 52; 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('CREW CARD', RX, ry + 14); ry += 30; ctx.fillStyle = p.text; ctx.font = 'bold 46px system-ui, sans-serif'; fillTextFit(ctx, (c?.name ?? '').toUpperCase(), RX, ry + 44, RMaxW); ry += 56; if (c?.location) { ctx.fillStyle = p.accent; ctx.font = 'bold 18px system-ui, sans-serif'; fillTextFit(ctx, `📍 ${c.location}`, RX, ry, RMaxW); ry += 24; } if (c?.founded) { ctx.fillStyle = p.muted; ctx.font = '13px system-ui, sans-serif'; ctx.fillText(`EST. ${c.founded}`, RX, ry); ry += 22; } if (c && c.lookingFor.length > 0) { ctx.fillStyle = p.accent2; ctx.font = 'bold 15px system-ui, sans-serif'; ctx.fillText('OPEN CALL', RX, ry); ry += 24; } if (c && c.vibes.length > 0) { let chipX = RX; let chipY = ry + 4; ctx.font = 'bold 14px system-ui, sans-serif'; c.vibes.slice(0, 5).forEach(v => { const label = v.toUpperCase(); const chipW = ctx.measureText(label).width + 18; if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; } roundRect(ctx, chipX, chipY, chipW, 22, 5); ctx.fillStyle = p.chipBg; ctx.fill(); ctx.strokeStyle = p.chipBorder; ctx.lineWidth = 0.8; ctx.stroke(); ctx.fillStyle = p.accent; ctx.fillText(label, chipX + 9, chipY + 16); chipX += chipW + 6; }); } // Full npub ctx.fillStyle = p.muted; ctx.font = '12px monospace'; ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64); ctx.fillText(data.npub.slice(44), RX, CARD_H - 48); } else { // ═══════════════════════════════════════════════════════════════════════ // EVENT PASS LAYOUT // ═══════════════════════════════════════════════════════════════════════ const e = data.event; 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.fillStyle = p.accent; ctx.font = '80px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('🎟️', SIDEBAR_W / 2, avY + 118); ctx.textAlign = 'left'; } // Right zone const RX = SIDEBAR_W + 28; const RMaxW = qrX - RX - 20; let ry = 52; 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('EVENT PASS', RX, ry + 14); ry += 30; ctx.fillStyle = p.text; ctx.font = 'bold 44px system-ui, sans-serif'; fillTextFit(ctx, (e?.title ?? '').toUpperCase(), RX, ry + 42, RMaxW); ry += 54; if (e) { ctx.fillStyle = p.accent; ctx.font = 'bold 18px system-ui, sans-serif'; fillTextFit(ctx, formatCardDate(e.start, e.end), RX, ry, RMaxW); ry += 26; if (e.location) { ctx.fillStyle = p.muted; ctx.font = '15px system-ui, sans-serif'; fillTextFit(ctx, `📍 ${e.location}`, RX, ry, RMaxW); ry += 24; } } // Holder ctx.fillStyle = p.muted; ctx.font = '13px system-ui, sans-serif'; ctx.fillText('ADMITS', RX, ry); ry += 4; ctx.fillStyle = p.accent2; ctx.font = 'bold 22px system-ui, sans-serif'; fillTextFit(ctx, e?.holder ?? '', RX, ry + 20, RMaxW); ry += 34; // Full npub ctx.fillStyle = p.muted; ctx.font = '12px monospace'; ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64); ctx.fillText(data.npub.slice(44), RX, CARD_H - 48); } // ─── 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'); } // ─── 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); } // ─── Received badges panel ──────────────────────────────────────────────────── function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) { const { t } = useTranslation(); const { data: badgeEvents = [] } = useReceivedBadges(pubkey); const [expanded, setExpanded] = useState(false); if (badgeEvents.length === 0) { return (
🏅

{t('labUi.rider.noBadges')}

); } const show = expanded ? badgeEvents : badgeEvents.slice(0, 3); return (
{show.map((event) => ( ))} {badgeEvents.length > 3 && ( )}
); } function BadgeEventRow({ event }: { event: NostrEvent }) { const { t } = useTranslation(); 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; })}

{t('labUi.rider.witnessedBy')} {witnessName} {spot && <> {t('labUi.rider.at')} {spot}}

{new Date(event.created_at * 1000).toLocaleDateString()}
); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function RiderCardPage() { const { t } = useTranslation(); 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 { data: savedCard } = useRiderCard(user?.pubkey); const { data: allSpots } = useSkateSpots(200); const { memberships } = useUserCrews(user?.pubkey); const { data: skaterProfile } = useUserSkaterProfile(user?.pubkey); const { data: roles = [] } = useRiderRoles(user?.pubkey); const crewCrests = useMemo( () => memberships.map(m => ({ name: m.crew.name, image: m.crew.image })), [memberships], ); const local = skaterProfile?.location || skaterProfile?.homeSpot || ''; 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' | 'crew' | 'event'>('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); const [showCrews, setShowCrews] = useState(true); const [showLocal, setShowLocal] = useState(true); const [showRoles, setShowRoles] = 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); // Venue binding: a park pass can name one of the issuer's OWN spots so a // verifier can confirm the issuer really owns the venue (see /verify). const [venueId, setVenueId] = useState(''); const [savedParkD, setSavedParkD] = useState(''); const [crewId, setCrewId] = useState(''); const activeCrew = memberships.find(m => m.crew.id === crewId)?.crew ?? memberships[0]?.crew; // Event pass / wristband: events you organize, or that you RSVP'd going to. const [eventId, setEventId] = useState(''); const { data: allEvents } = useSkateEvents(200); const { data: rsvpCoords = [] } = useMyRsvpCoords(user?.pubkey); const myEvents = useMemo(() => { const rsvpSet = new Set(rsvpCoords); return (allEvents ?? []).filter(ev => ev.pubkey === user?.pubkey || rsvpSet.has(eventCoord(ev))); }, [allEvents, user?.pubkey, rsvpCoords]); const activeEvent = myEvents.find(ev => ev.id === eventId) ?? myEvents[0]; const eventRsvp = useEventRsvps(cardType === 'event' && activeEvent ? eventCoord(activeEvent) : undefined); const myVenues = useMemo( () => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey), [allSpots, user?.pubkey], ); const venueSpot = myVenues.find((s) => s.id === venueId); // Hydrate the editor from the saved Rider Card the first time it loads. const hydratedRef = useRef(false); useEffect(() => { if (hydratedRef.current || !savedCard?.config) return; hydratedRef.current = true; const c = savedCard.config; setTheme(c.theme as ThemeId); setStyle(c.style); setSkills(c.skills); setSponsors(c.sponsors); setRidesFor(c.ridesFor); setShowSpots(c.showSpots); setShowZaps(c.showZaps); setShowEvents(c.showEvents); setShowCrews(c.showCrews); setShowLocal(c.showLocal); setShowRoles(c.showRoles); }, [savedCard]); 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 ?? '') : cardType === 'park' ? recipientPubkey : cardType === 'crew' ? (activeCrew?.pubkey ?? user?.pubkey ?? '') : (activeEvent?.pubkey ?? ''); const cardData: CardData = useMemo( () => ({ type: cardType, theme, holographic, name: cardType === 'rider' ? displayName : recipientName, nip05: metadata?.nip05, avatar: cardType === 'crew' ? activeCrew?.image : cardType === 'event' ? activeEvent?.image : metadata?.picture, pubkey: cardPubkey, npub: cardPubkey ? fullNpub(cardPubkey) : '', style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, crews: crewCrests, local: local || undefined, roles, showCrews, showLocal, showRoles, 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, verifyUrl: cardType === 'rider' ? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : undefined) : cardType === 'park' ? (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined) : cardType === 'crew' ? (activeCrew ? cardVerifyUrl(SKATE_CREW_KIND, activeCrew.pubkey, activeCrew.identifier) : undefined) : (activeEvent ? cardVerifyUrl(CALENDAR_EVENT_KIND, activeEvent.pubkey, activeEvent.identifier) : undefined), crew: cardType === 'crew' && activeCrew ? { name: activeCrew.name, image: activeCrew.image, pubkey: activeCrew.pubkey, identifier: activeCrew.identifier, location: activeCrew.location, founded: activeCrew.founded, members: activeCrew.members.length, focus: activeCrew.focus, vibes: activeCrew.vibes, lookingFor: activeCrew.lookingFor, spots: activeCrew.spotRefs.length, } : undefined, event: cardType === 'event' && activeEvent ? { title: activeEvent.title, start: activeEvent.start, end: activeEvent.end, location: activeEvent.location, image: activeEvent.image, pubkey: activeEvent.pubkey, identifier: activeEvent.identifier, going: eventRsvp.goingCount, holder: displayName, } : undefined, issuerName, recipientName, accessLevel, validThru, }), [ cardType, theme, holographic, displayName, recipientName, metadata, cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey, crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount, ], ); 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, verifyUrl: cardData.verifyUrl, local: cardData.local, crews: (cardData.crews ?? []).map(c => c.name), roles: cardData.roles, crew: cardData.crew, event: cardData.event, 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 handleSaveCard = useCallback(() => { if (!user) return; const config: RiderCardConfig = { theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles }; publishEvent( { kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) }, { onSuccess: () => toast({ title: t('cardUi.saved'), description: t('cardUi.savedDesc') }), onError: (e) => toast({ title: t('cardUi.saveFailed'), description: String(e), variant: 'destructive' }), }, ); }, [user, theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, publishEvent, toast, t]); const handlePrint = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), 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: t('labUi.rider.popupBlocked'), description: t('labUi.rider.popupBlockedDesc'), variant: 'destructive' }); return; } win.document.write(`SkateSpots Rider Cardwindow.onload=function(){window.focus();window.print()}`); win.document.close(); }, [renderCardToImage, toast, t]); const handleDownload = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), 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, t]); 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: t('labUi.rider.couldNotRender'), 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: t('labUi.rider.linkCopied') }); }, [renderCardToImage, cardData.name, cardData.npub, toast, t]); const handlePublishParkCard = useCallback(() => { if (!user || !recipientPubkey) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; } const d = `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`; const tags: string[][] = [ ['d', d], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'], ['issuer', issuerName], ['access-level', accessLevel], ['theme', theme], ]; if (validThru) tags.push(['valid-thru', validThru]); // Bind the pass to the venue the issuer owns, so /verify can confirm it. if (venueSpot) tags.push(['a', `${SKATE_SPOT_KIND}:${venueSpot.pubkey}:${venueSpot.identifier}`]); publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { onSuccess: () => { setSavedParkD(d); toast({ title: t('labUi.rider.parkPublished') }); }, onError: (e) => toast({ title: t('labUi.rider.publishFailed'), description: String(e), variant: 'destructive' }), }); }, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, venueSpot, publishEvent, toast, t]); const toggleSkill = (id: string) => setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]); // ── Logged out ──────────────────────────────────────────────────────────── if (!user) { return ( <>
{/* Hero */}
🛹

RIDER CARD

{t('labUi.rider.heroDesc')}

{t('labUi.rider.heroDesc2')}

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

RIDER CARD

{t('labUi.rider.headerTagline')}

{/* ── Top tabs: My Card / My Badges / Award Badge ── */}
{([ { id: 'card', label: `🪪 ${t('labUi.rider.tabCard')}` }, { id: 'badges', label: `🏅 ${t('labUi.rider.tabBadges')}${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` }, { id: 'award', label: `⭐ ${t('labUi.rider.tabAward')}` }, ] as const).map(tab => ( ))}
{/* ══════════════════════════════════════════════ TAB: MY CARD ══════════════════════════════════════════════ */} {pageTab === 'card' && (
{/* ── Hero card preview ── */}
{/* Stage */}
{/* Subtle glow behind card */}
{/* Primary CTA */} {/* Secondary actions row */}
{/* Save the card to Nostr so it persists & verifies */} {cardType === 'rider' && ( )}
{/* ── Card type toggle ── */}

{t('labUi.rider.cardType')}

{([ { id: 'rider', label: t('labUi.rider.licenseLabel'), sub: t('labUi.rider.licenseSub'), icon: User }, { id: 'park', label: t('labUi.rider.parkLabel'), sub: t('labUi.rider.parkSub'), icon: Building2 }, { id: 'crew', label: t('cardUi.crew.crewCard'), sub: t('cardUi.crew.crewSub'), icon: Users }, { id: 'event', label: t('cardUi.event.pass'), sub: t('cardUi.event.passSub'), icon: Ticket }, ] as const).map(({ id, label, sub, icon: Icon }) => ( ))}
{/* ── Theme picker ── */}

{t('labUi.rider.theme')}

{THEMES.map((th) => ( ))}
{/* ── Holographic toggle ── */}
✨ {t('labUi.rider.holoTitle')}
{t('labUi.rider.holoDesc')}
{/* ── RIDER fields ── */} {cardType === 'rider' && (

{t('labUi.rider.cardDetails')}

{/* 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: t('labUi.pyramid.spots'), value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots }, { label: t('labUi.rider.zaps'), value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps }, { label: t('labUi.pyramid.events'), value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents }, ].map(({ label, value, state, set }) => ( ))}
{/* Identity facets */}
{[ { label: t('cardUi.facets.crews'), value: crewCrests.length, state: showCrews, set: setShowCrews }, { label: t('cardUi.facets.local'), value: local ? '📍' : '—', state: showLocal, set: setShowLocal }, { label: t('cardUi.facets.roles'), value: roles.length, state: showRoles, set: setShowRoles }, ].map(({ label, value, state, set }) => ( ))}
{/* Skills */}
{t('labUi.rider.selected', { count: skills.length })}
{SKILL_OPTIONS.map(s => ( ))}

⭐ {t('labUi.rider.goldBadgesHint1')} {t('labUi.rider.goldBadgesHint2')}

)} {/* ── PARK fields ── */} {cardType === 'park' && (
{t('labUi.rider.parkOwnersOnly')}{' '} {t('labUi.rider.parkOwnersDesc')}

{t('labUi.rider.parkDetails')}

setIssuerName(e.target.value)} placeholder={t('labUi.rider.parkNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
setRecipientName(e.target.value)} placeholder={t('labUi.rider.riderNamePlaceholder')} 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" />

{t('cardUi.venueHint')}

)} {/* ── CREW fields ── */} {cardType === 'crew' && (

{t('cardUi.crew.pick')}

{memberships.length > 0 ? ( ) : (

{t('cardUi.crew.none')}

)}

{t('cardUi.crew.hint')}

)} {/* ── EVENT fields (pass / wristband) ── */} {cardType === 'event' && (

{t('cardUi.event.pick')}

{myEvents.length > 0 ? ( ) : (

{t('cardUi.event.none')}

)}

{t('cardUi.event.hint')}

)}
)} {/* ══════════════════════════════════════════════ TAB: MY BADGES ══════════════════════════════════════════════ */} {pageTab === 'badges' && (

{t('labUi.rider.badgesIntro')}

{t('labUi.rider.earnMore')}

)} {/* ══════════════════════════════════════════════ TAB: AWARD BADGE (inline, no dialog) ══════════════════════════════════════════════ */} {pageTab === 'award' && (
{t('labUi.rider.youSawIt')}
{t('labUi.rider.youSawItDesc')}
{/* 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 { t } = useTranslation(); 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: t('labUi.rider.addRiderSkill'), 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: t('labUi.rider.invalidNpub'), 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: t('labUi.rider.badgeAwarded'), description: t('labUi.rider.badgeSent', { skills: selectedSkills.join(', ') }) }); setRecipientNpub(''); setSelectedSkills([]); setSpotName(''); onSuccess(); }, onError: (e) => toast({ title: t('labUi.rider.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" />
); }