SkateSpots/src/pages/RiderCardPage.tsx
SkateSpots 7cafcc0dfa feat(card,review): Event pass/wristband template + Claims tab in /review
- /card gains a fourth type: Event. Renders an event (kind 31923) you organise
  or RSVP'd going/interested to (useMyRsvpCoords, kind 31925) as a passport/
  wristband: title, date/time, venue, cover, who it admits, going count.
  Derived from the event's own signed event — its QR verifies that event;
  /verify/:naddr renders kind 31923 too. React + canvas renderers; i18n.
- /review gains a dedicated Claims tab (pending private-spot ownership claims),
  replacing the banner that sat above the tabs.
2026-10-02 11:13:40 +07:00

1673 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, {
bg1: string; bg2: string; bg3: string;
accent: string; accent2: string;
stripe1: string; stripe2: string;
muted: string; chipBg: string; chipBorder: string;
text: string; shopColor: string; sponsorColor: string;
sidebarBg: string;
}> = {
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<HTMLImageElement | null> {
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<string> {
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 (
<div className="p-3 rounded-xl bg-black/40 border border-gray-700 text-center">
<div className="text-2xl mb-1">🏅</div>
<p className="text-xs text-gray-500">{t('labUi.rider.noBadges')}</p>
</div>
);
}
const show = expanded ? badgeEvents : badgeEvents.slice(0, 3);
return (
<div className="space-y-2">
{show.map((event) => (
<BadgeEventRow key={event.id} event={event} />
))}
{badgeEvents.length > 3 && (
<button
onClick={() => setExpanded(p => !p)}
className="w-full text-xs text-gray-500 hover:text-gray-300 flex items-center justify-center gap-1 py-1"
>
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{expanded ? t('common.showLess') : t('labUi.rider.showMore', { count: badgeEvents.length - 3 })}
</button>
)}
</div>
);
}
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 (
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
<div className="flex-shrink-0">
<Avatar className="h-7 w-7">
<AvatarImage src={witnessAvatar} />
<AvatarFallback className="text-[10px] bg-yellow-900">{witnessName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap gap-1 mb-0.5">
{skills.map(sk => {
const s = SKILL_OPTIONS.find(o => o.id === sk);
return s ? (
<Badge key={sk} variant="secondary" className="text-[9px] px-1.5 py-0 bg-yellow-500/20 text-yellow-300 border-yellow-500/30 h-4">
{s.icon} {s.label}
</Badge>
) : null;
})}
</div>
<p className="text-[10px] text-gray-400">
{t('labUi.rider.witnessedBy')} <span className="text-yellow-400 font-medium">{witnessName}</span>
{spot && <> {t('labUi.rider.at')} <span className="text-gray-300">{spot}</span></>}
</p>
</div>
<div className="text-[9px] text-gray-600 flex-shrink-0">
{new Date(event.created_at * 1000).toLocaleDateString()}
</div>
</div>
);
}
// ─── 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<HTMLDivElement>(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<ThemeId>('thug');
const [holographic, setHolographic] = useState(true);
// Rider fields
const [style, setStyle] = useState('Street');
const [skills, setSkills] = useState<string[]>([]);
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<string | null> => {
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(`<!DOCTYPE html><html><head><title>SkateSpots Rider Card</title><style>@page{size:85.6mm 54mm;margin:0}html,body{margin:0;padding:0;background:#000}img{display:block;width:85.6mm;height:54mm}</style></head><body><img src="${dataUrl}"/><scr${''}ipt>window.onload=function(){window.focus();window.print()}</scr${''}ipt></body></html>`);
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 (
<>
<div className="min-h-[70vh] flex flex-col items-center justify-center px-4 text-center">
{/* Hero */}
<div className="mb-8 relative">
<div className="absolute inset-0 blur-3xl opacity-20 bg-gradient-to-br from-[#ff0040] to-[#ccff00] rounded-full" />
<div className="relative text-7xl mb-4">🛹</div>
</div>
<h1 className="text-4xl font-black uppercase mb-2">
<span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span>
</h1>
<p className="text-gray-400 max-w-sm mb-2">
{t('labUi.rider.heroDesc')}
</p>
<p className="text-gray-600 text-sm mb-8 max-w-xs">
{t('labUi.rider.heroDesc2')}
</p>
<LoginArea className="justify-center" />
</div>
</>
);
}
return (
<>
<div className="max-w-2xl mx-auto px-4 pb-16">
{/* ── Page header ── */}
<div className="pt-8 pb-6 text-center">
<h1 className="text-3xl font-black uppercase tracking-tight mb-1">
<span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span>
</h1>
<p className="text-gray-500 text-xs tracking-widest uppercase">{t('labUi.rider.headerTagline')}</p>
</div>
{/* ── Top tabs: My Card / My Badges / Award Badge ── */}
<div className="flex rounded-xl overflow-hidden border border-white/10 mb-8 bg-black/40">
{([
{ 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 => (
<button
key={tab.id}
onClick={() => setPageTab(tab.id)}
className={cn(
'flex-1 py-3 text-xs font-black uppercase tracking-widest transition-all',
pageTab === tab.id
? 'bg-[#ff0040] text-white'
: 'text-gray-500 hover:text-gray-300 hover:bg-white/5'
)}
>
{tab.label}
</button>
))}
</div>
{/* ══════════════════════════════════════════════
TAB: MY CARD
══════════════════════════════════════════════ */}
{pageTab === 'card' && (
<div className="space-y-8">
{/* ── Hero card preview ── */}
<div className="flex flex-col items-center gap-4">
{/* Stage */}
<div
className="w-full rounded-2xl flex items-center justify-center py-8 relative overflow-hidden"
style={{ background: 'radial-gradient(ellipse at 50% 60%, rgba(255,0,64,0.08) 0%, rgba(0,0,0,0) 70%), #0a0a0a', border: '1px solid rgba(255,255,255,0.06)' }}
>
{/* Subtle glow behind card */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-32 blur-3xl opacity-30"
style={{ background: `radial-gradient(ellipse, ${theme === 'lime' ? '#ccff00' : theme === 'midnight' ? '#64a0ff' : theme === 'wood' ? '#f5c842' : theme === 'chrome' ? '#888' : theme === 'sunset' ? '#ff6020' : '#ff0040'}, transparent)` }}
/>
<RiderCardFront d={cardData} printRef={printRef} />
</div>
{/* Primary CTA */}
<Button
onClick={handleShare}
disabled={rendering}
size="lg"
className="w-full h-14 bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 font-black uppercase text-base rounded-xl shadow-xl shadow-[#ff0040]/20 gap-2"
>
{rendering ? <Loader2 className="h-5 w-5 animate-spin" /> : <Share2 className="h-5 w-5" />}
{rendering ? t('labUi.rider.rendering') : t('labUi.rider.shareCard')}
</Button>
{/* Secondary actions row */}
<div className="grid grid-cols-3 gap-2 w-full">
<Button
variant="outline"
onClick={handleDownload}
disabled={rendering}
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
>
<Download className="h-3.5 w-3.5" />
{t('labUi.rider.savePng')}
</Button>
<Button
variant="outline"
onClick={handlePrint}
disabled={rendering}
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
>
<Printer className="h-3.5 w-3.5" />
{t('labUi.rider.print')}
</Button>
<Button
variant="outline"
onClick={handleCopyNpub}
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? t('common.copied') : t('labUi.rider.copyId')}
</Button>
</div>
{/* Save the card to Nostr so it persists & verifies */}
{cardType === 'rider' && (
<Button
onClick={handleSaveCard}
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
>
<Check className="h-4 w-4" />
{t('cardUi.save')}
</Button>
)}
</div>
{/* ── Card type toggle ── */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2">{t('labUi.rider.cardType')}</p>
<div className="grid grid-cols-2 gap-2">
{([
{ 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 }) => (
<button
key={id}
onClick={() => setCardType(id)}
className={cn(
'p-4 rounded-xl border text-left transition-all',
cardType === id
? 'bg-[#ff0040]/10 border-[#ff0040]/60 text-white'
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20 hover:text-gray-300'
)}
>
<Icon className={cn('h-5 w-5 mb-2', cardType === id ? 'text-[#ff0040]' : 'text-gray-600')} />
<div className="text-xs font-black uppercase">{label}</div>
<div className="text-[10px] mt-0.5 opacity-60">{sub}</div>
</button>
))}
</div>
</div>
{/* ── Theme picker ── */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2 flex items-center gap-1.5">
<Palette className="h-3 w-3" /> {t('labUi.rider.theme')}
</p>
<div className="grid grid-cols-6 gap-2">
{THEMES.map((th) => (
<button
key={th.id}
onClick={() => setTheme(th.id)}
className={cn(
'aspect-square rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-0.5',
`card-theme-${th.id}`,
theme === th.id
? 'ring-2 ring-white/50 ring-offset-1 ring-offset-black scale-105 border-white/30'
: 'border-white/10 opacity-60 hover:opacity-90'
)}
>
<span className="text-xl">{th.emoji}</span>
<span className="text-[8px] font-black uppercase opacity-80">{th.label}</span>
</button>
))}
</div>
</div>
{/* ── Holographic toggle ── */}
<div className="flex items-center justify-between px-4 py-3 rounded-xl bg-white/3 border border-white/8">
<div>
<div className="text-sm font-bold">✨ {t('labUi.rider.holoTitle')}</div>
<div className="text-xs text-gray-500">{t('labUi.rider.holoDesc')}</div>
</div>
<Switch checked={holographic} onCheckedChange={setHolographic} />
</div>
{/* ── RIDER fields ── */}
{cardType === 'rider' && (
<div className="space-y-5">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('labUi.rider.cardDetails')}</p>
{/* Style */}
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.skateStyle')}</Label>
<Select value={style} onValueChange={setStyle}>
<SelectTrigger className="bg-black/50 border-white/10 h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STYLES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Rides For + Sponsors — side by side */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.ridesFor')}</Label>
<Input
placeholder={t('labUi.rider.ridesForPlaceholder')}
value={ridesFor}
onChange={e => setRidesFor(e.target.value)}
className="bg-black/50 border-white/10 text-sm h-11"
/>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.sponsors')}</Label>
<Input
placeholder={t('labUi.rider.sponsorsPlaceholder')}
value={sponsors}
onChange={e => setSponsors(e.target.value)}
className="bg-black/50 border-white/10 text-sm h-11"
/>
</div>
</div>
{/* Stats toggles */}
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('labUi.rider.showOnCard')}</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ 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 }) => (
<button
key={label}
onClick={() => set(!state)}
className={cn(
'p-3 rounded-xl border text-center transition-all',
state
? 'bg-[#ff0040]/10 border-[#ff0040]/40 text-white'
: 'bg-black/30 border-white/8 text-gray-600'
)}
>
<div className={cn('text-lg font-black', state ? 'text-[#ff0040]' : 'text-gray-700')}>{value}</div>
<div className="text-[9px] uppercase tracking-wide mt-0.5">{label}</div>
</button>
))}
</div>
</div>
{/* Identity facets */}
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('cardUi.facets.title')}</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ 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 }) => (
<button
key={label}
onClick={() => set(!state)}
className={cn(
'p-3 rounded-xl border text-center transition-all',
state
? 'bg-[#ff0040]/10 border-[#ff0040]/40 text-white'
: 'bg-black/30 border-white/8 text-gray-600'
)}
>
<div className={cn('text-lg font-black', state ? 'text-[#ff0040]' : 'text-gray-700')}>{value}</div>
<div className="text-[9px] uppercase tracking-wide mt-0.5">{label}</div>
</button>
))}
</div>
</div>
{/* Skills */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.skills')}</Label>
<span className="text-[10px] text-gray-600">{t('labUi.rider.selected', { count: skills.length })}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{SKILL_OPTIONS.map(s => (
<button
key={s.id}
onClick={() => toggleSkill(s.id)}
disabled={!skills.includes(s.id) && skills.length >= 6}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-bold border transition-all',
skills.includes(s.id)
? 'bg-[#ff0040]/15 border-[#ff0040]/50 text-white'
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20 disabled:opacity-25 disabled:cursor-not-allowed'
)}
>
{s.icon} {s.label}
</button>
))}
</div>
<p className="text-[10px] text-gray-600 mt-2">
⭐ {t('labUi.rider.goldBadgesHint1')} <button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">{t('labUi.rider.tabBadges')}</button>{t('labUi.rider.goldBadgesHint2')}
</p>
</div>
</div>
)}
{/* ── PARK fields ── */}
{cardType === 'park' && (
<div className="space-y-5">
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/5 border border-blue-500/20 text-xs text-gray-400">
<Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<span className="font-bold text-blue-300">{t('labUi.rider.parkOwnersOnly')}</span>{' '}
{t('labUi.rider.parkOwnersDesc')}
</div>
</div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('labUi.rider.parkDetails')}</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.parkName')}</Label>
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
placeholder={t('labUi.rider.parkNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderName')}</Label>
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
placeholder={t('labUi.rider.riderNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
</div>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderNpub')}</Label>
<Input
value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)}
placeholder="npub1…"
className="bg-black/50 border-white/10 font-mono text-xs h-11"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.accessLevel')}</Label>
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
<SelectContent>
{['Full Access','Supervised','Beginner Zone','Street Only','Bowl Only','Members Only'].map(l =>
<SelectItem key={l} value={l}>{l}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.validThru')}</Label>
<Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
placeholder="2026-12-31" className="bg-black/50 border-white/10 h-11" />
</div>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('cardUi.venue')}</Label>
<Select value={venueId || '__none'} onValueChange={(v) => setVenueId(v === '__none' ? '' : v)}>
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue placeholder={t('cardUi.venueNone')} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('cardUi.venueNone')}</SelectItem>
{myVenues.map(s => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-[10px] text-gray-600 mt-1.5">{t('cardUi.venueHint')}</p>
</div>
<Button
onClick={handlePublishParkCard}
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
>
<Zap className="h-4 w-4" />
{t('labUi.rider.publishToNostr')}
</Button>
</div>
)}
{/* ── CREW fields ── */}
{cardType === 'crew' && (
<div className="space-y-5">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('cardUi.crew.pick')}</p>
{memberships.length > 0 ? (
<Select value={activeCrew?.id ?? ''} onValueChange={setCrewId}>
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
<SelectContent>
{memberships.map(m => (
<SelectItem key={m.crew.id} value={m.crew.id}>{m.crew.name} · {m.role}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-gray-500">{t('cardUi.crew.none')}</p>
)}
<p className="text-[10px] text-gray-600">{t('cardUi.crew.hint')}</p>
</div>
)}
{/* ── EVENT fields (pass / wristband) ── */}
{cardType === 'event' && (
<div className="space-y-5">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('cardUi.event.pick')}</p>
{myEvents.length > 0 ? (
<Select value={activeEvent?.id ?? ''} onValueChange={setEventId}>
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
<SelectContent>
{myEvents.map(ev => (
<SelectItem key={ev.id} value={ev.id}>{ev.title}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-xs text-gray-500">{t('cardUi.event.none')}</p>
)}
<p className="text-[10px] text-gray-600">{t('cardUi.event.hint')}</p>
</div>
)}
</div>
)}
{/* ══════════════════════════════════════════════
TAB: MY BADGES
══════════════════════════════════════════════ */}
{pageTab === 'badges' && (
<div className="space-y-6">
<div className="text-center py-2">
<p className="text-gray-400 text-sm max-w-xs mx-auto">
{t('labUi.rider.badgesIntro')}
</p>
</div>
<ReceivedBadgesPanel pubkey={user.pubkey} />
<div className="pt-2 border-t border-white/8">
<p className="text-xs text-gray-600 text-center mb-3">{t('labUi.rider.earnMore')}</p>
<Button
variant="outline"
onClick={() => setPageTab('award')}
className="w-full border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 gap-2"
>
<Award className="h-4 w-4" />
{t('labUi.rider.awardSomeone')}
</Button>
</div>
</div>
)}
{/* ══════════════════════════════════════════════
TAB: AWARD BADGE (inline, no dialog)
══════════════════════════════════════════════ */}
{pageTab === 'award' && (
<div className="space-y-5">
<div className="flex items-center gap-3 p-4 rounded-xl bg-yellow-500/5 border border-yellow-500/20">
<Award className="h-5 w-5 text-yellow-400 flex-shrink-0" />
<div>
<div className="text-sm font-bold text-yellow-300">{t('labUi.rider.youSawIt')}</div>
<div className="text-xs text-gray-400 mt-0.5">{t('labUi.rider.youSawItDesc')}</div>
</div>
</div>
{/* Inline award form — the dialog was removed as dead code */}
<AwardBadgeInline issuerPubkey={user.pubkey} onSuccess={() => setPageTab('badges')} />
</div>
)}
</div>
</>
);
}
// ─── 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<string[]>([]);
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 (
<div className="space-y-5">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderNpub')}</Label>
<Input
value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)}
placeholder="npub1…"
className="bg-black/50 border-white/10 font-mono text-xs h-11"
/>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('labUi.rider.skillsWitnessed')}</Label>
<div className="flex flex-wrap gap-1.5">
{SKILL_OPTIONS.map(s => (
<button
key={s.id}
onClick={() => toggleSkill(s.id)}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-bold border transition-all',
selectedSkills.includes(s.id)
? 'bg-yellow-500/15 border-yellow-400/50 text-yellow-200'
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20'
)}
>
{s.icon} {s.label}
</button>
))}
</div>
</div>
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.whereSeen')} <span className="normal-case text-gray-600">{t('labUi.rider.optional')}</span></Label>
<Input
value={spotName}
onChange={e => setSpotName(e.target.value)}
placeholder="MACBA, The Berrics, Burnside…"
className="bg-black/50 border-white/10 text-sm h-11"
/>
</div>
<Button
onClick={handleAward}
disabled={isPending || selectedSkills.length === 0 || !recipientNpub.trim()}
className="w-full h-12 bg-yellow-500 hover:bg-yellow-400 text-black font-black uppercase gap-2"
>
<Award className="h-4 w-4" />
{isPending ? t('labUi.rider.signingOnNostr') : t('labUi.rider.awardBadge')}
</Button>
</div>
);
}