SkateSpots/src/pages/RiderCardPage.tsx
SkateSpots 10ab45383d chore(ridercard): delete the unused AwardBadgeDialog
showAwardDialog was never set true, so the dialog could never open; the inline
AwardBadgeInline form is the live flow. Removes ~142 lines plus its now-unused
Dialog imports, state and helper.
2026-09-26 19:14:21 +07:00

1596 lines
72 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, useRef, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
// QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
// access at module load time (crashes the preview environment)
import { Layout } from '@/components/Layout';
import { LoginArea } from '@/components/auth/LoginArea';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useRiderStats } from '@/hooks/useRiderStats';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useNostr } from '@/hooks/useNostr';
import { useQuery } from '@tanstack/react-query';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Printer,
Loader2,
Share2,
Download,
Check,
Copy,
Award,
Zap,
Info,
Palette,
Shield,
ChevronUp,
ChevronDown,
User,
Building2,
} from 'lucide-react';
// ─── Card canvas renderer ─────────────────────────────────────────────────────
// Draws the card entirely via Canvas 2D API — 100% reliable export,
// no CSS capture, no scaling issues, no background-stripping.
// Card is 856×540px (10× credit-card mm, high-res for printing).
const CARD_W = 856;
const CARD_H = 540;
interface CanvasCardData {
type: 'rider' | 'park';
themeId: string;
name: string;
nip05?: string;
avatarUrl?: string;
pubkey: string;
npub: string;
style: string;
skills: string[];
sponsors: string;
ridesFor: string;
showSpots: boolean;
showZaps: boolean;
showEvents: boolean;
spotsAdded: number;
zapsReceived: number;
eventsCreated: number;
memberYear: string;
receivedBadges: BadgeAward[];
// Park
issuerName: string;
recipientName: string;
accessLevel: string;
validThru: string;
}
// Theme palette for canvas drawing
const THEME_PALETTE: Record<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) ────────────
const qrUrl = `https://skate-spots.org/${data.npub}`;
const qrSize = 168;
const qrX = CARD_W - 32 - qrSize;
const qrY = 28;
let qrImg: HTMLImageElement | null = null;
try {
// Dynamic import — avoids canvas API access at module load time
const QRCode = (await import('qrcode')).default;
const qrDataUrl = await QRCode.toDataURL(qrUrl, {
width: qrSize * 2,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
});
qrImg = await loadImage(qrDataUrl); // data: URI — no proxy
} catch { /* skip */ }
if (qrImg) {
roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 10);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
}
ctx.fillStyle = p.muted;
ctx.font = '13px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCAN TO VERIFY', qrX + qrSize / 2, qrY + qrSize + 20);
ctx.textAlign = 'left';
// ─── 5. Load avatar ───────────────────────────────────────────────────────
let avatarImg: HTMLImageElement | null = null;
if (data.avatarUrl) avatarImg = await loadImage(data.avatarUrl);
if (data.type === 'rider') {
// ═══════════════════════════════════════════════════════════════════════
// SKATER LICENSE LAYOUT
// Left sidebar: avatar + stats + since
// Right zone: name / nip05 / style / shop / sponsors / skills
// ═══════════════════════════════════════════════════════════════════════
// ── Left: Avatar ─────────────────────────────────────────────────────
const avSize = 176;
const avX = (SIDEBAR_W - avSize) / 2;
const avY = 22;
roundRect(ctx, avX, avY, avSize, avSize, 14);
ctx.fillStyle = p.chipBg;
ctx.fill();
if (avatarImg) {
ctx.save();
roundRect(ctx, avX, avY, avSize, avSize, 14);
ctx.clip();
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
ctx.restore();
} else {
ctx.font = '72px serif';
ctx.textAlign = 'center';
ctx.fillStyle = p.accent;
ctx.fillText('🛹', avX + avSize / 2, avY + avSize / 2 + 24);
ctx.textAlign = 'left';
}
// ── Left: Stats block ─────────────────────────────────────────────────
const statsStartY = avY + avSize + 20;
const statItems = [
data.showSpots ? { label: 'SPOTS', value: String(data.spotsAdded), color: p.accent } : null,
data.showZaps ? { label: 'ZAPS ⚡', value: String(data.zapsReceived), color: p.accent2 } : null,
data.showEvents ? { label: 'EVENTS', value: String(data.eventsCreated), color: '#ff7733' } : null,
].filter(Boolean) as { label: string; value: string; color: string }[];
const statColW = SIDEBAR_W / Math.max(statItems.length, 1);
statItems.forEach((s, i) => {
const sx = i * statColW + statColW / 2;
ctx.fillStyle = p.muted;
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(s.label, sx, statsStartY);
ctx.fillStyle = s.color;
ctx.font = 'bold 30px system-ui, sans-serif';
ctx.fillText(s.value, sx, statsStartY + 34);
});
ctx.textAlign = 'left';
// ── Left: Since + npub ────────────────────────────────────────────────
const sinceY = statsStartY + (statItems.length > 0 ? 56 : 0);
ctx.fillStyle = p.muted;
ctx.font = '13px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`SINCE ${data.memberYear}`, SIDEBAR_W / 2, sinceY);
ctx.textAlign = 'left';
// ── Right zone ────────────────────────────────────────────────────────
const RX = SIDEBAR_W + 28; // right content X start
const RMaxW = qrX - RX - 20; // max width before QR
let ry = 52; // right Y cursor
// Brand tag (small, above name)
ctx.fillStyle = p.accent;
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.fillText('🛹 SKATESPOTS', RX, ry);
ry += 6;
ctx.fillStyle = p.muted;
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('SKATER LICENSE', RX, ry + 14);
ry += 30;
// Name — big and bold
ctx.fillStyle = p.text;
ctx.font = 'bold 52px system-ui, sans-serif';
fillTextFit(ctx, data.name.toUpperCase(), RX, ry + 48, RMaxW);
ry += 58;
// nip05
if (data.nip05) {
ctx.fillStyle = p.accent;
ctx.font = 'italic 20px Georgia, serif';
fillTextFit(ctx, `@${data.nip05.split('@')[0]}`, RX, ry, RMaxW);
ry += 28;
}
// Thin divider
ctx.strokeStyle = p.accent;
ctx.globalAlpha = 0.2;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(RX, ry); ctx.lineTo(RX + RMaxW, ry); ctx.stroke();
ctx.globalAlpha = 1;
ry += 18;
// Style chip
if (data.style) {
ctx.font = 'bold 16px system-ui, sans-serif';
const chipTxt = data.style.toUpperCase();
const chipW = ctx.measureText(chipTxt).width + 24;
roundRect(ctx, RX, ry - 18, chipW, 26, 6);
ctx.fillStyle = p.chipBg;
ctx.fill();
ctx.strokeStyle = p.chipBorder;
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = p.accent;
ctx.fillText(chipTxt, RX + 12, ry - 1);
ry += 22;
}
// ── SKATE SHOP — distinctive color + italic font ──────────────────────
if (data.ridesFor) {
ctx.fillStyle = p.muted;
ctx.font = '11px system-ui, sans-serif';
ctx.fillText('RIDES FOR', RX, ry);
ry += 4;
ctx.fillStyle = p.shopColor;
ctx.font = 'bold italic 22px Georgia, serif';
fillTextFit(ctx, data.ridesFor, RX, ry + 20, RMaxW);
ry += 32;
}
// ── SPONSORS — different color + lighter weight ────────────────────────
if (data.sponsors) {
ctx.fillStyle = p.muted;
ctx.font = '11px system-ui, sans-serif';
ctx.fillText('SPONSORS', RX, ry);
ry += 4;
ctx.fillStyle = p.sponsorColor;
ctx.font = '19px system-ui, sans-serif';
fillTextFit(ctx, data.sponsors, RX, ry + 18, RMaxW);
ry += 30;
}
// Thin divider before skills
if (data.skills.length > 0 || data.receivedBadges.length > 0) {
ctx.strokeStyle = p.accent;
ctx.globalAlpha = 0.15;
ctx.lineWidth = 1;
const divY = Math.max(ry + 4, CARD_H - 130);
ctx.beginPath(); ctx.moveTo(RX, divY); ctx.lineTo(RX + RMaxW, divY); ctx.stroke();
ctx.globalAlpha = 1;
// ── Skill chips ────────────────────────────────────────────────────
const skillsToShow = [
...data.skills.slice(0, 5).map(id => {
const s = SKILL_OPTIONS.find(o => o.id === id);
return s ? { label: s.label, gold: false } : null;
}).filter(Boolean),
...data.receivedBadges.slice(0, 3).map(b => {
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
return s ? { label: `★ ${s.label}`, gold: true } : null;
}).filter(Boolean),
] as { label: string; gold: boolean }[];
let chipX = RX;
let chipY = divY + 14;
ctx.font = 'bold 14px system-ui, sans-serif';
skillsToShow.forEach(chip => {
const chipW = ctx.measureText(chip.label).width + 18;
if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; }
roundRect(ctx, chipX, chipY, chipW, 22, 5);
ctx.fillStyle = chip.gold ? 'rgba(255,215,0,0.18)' : p.chipBg;
ctx.fill();
ctx.strokeStyle = chip.gold ? 'rgba(255,215,0,0.5)' : p.chipBorder;
ctx.lineWidth = 0.8;
ctx.stroke();
ctx.fillStyle = chip.gold ? '#ffd700' : p.accent;
ctx.fillText(chip.label, chipX + 9, chipY + 16);
chipX += chipW + 6;
});
}
// ── Full npub — bottom of right zone ──────────────────────────────────
const npubY = CARD_H - 44;
ctx.fillStyle = p.muted;
ctx.font = '12px monospace';
ctx.fillText(data.npub.slice(0, 44), RX, npubY);
ctx.fillText(data.npub.slice(44), RX, npubY + 16);
} else {
// ═══════════════════════════════════════════════════════════════════════
// PARK ACCESS LAYOUT
// ═══════════════════════════════════════════════════════════════════════
// ── Left: Avatar ─────────────────────────────────────────────────────
const avSize = 176;
const avX = (SIDEBAR_W - avSize) / 2;
const avY = 22;
roundRect(ctx, avX, avY, avSize, avSize, 14);
ctx.fillStyle = p.chipBg;
ctx.fill();
if (avatarImg) {
ctx.save();
roundRect(ctx, avX, avY, avSize, avSize, 14);
ctx.clip();
ctx.drawImage(avatarImg, avX, avY, avSize, avSize);
ctx.restore();
}
// Access level chip under avatar
const alTxt = (data.accessLevel || 'FULL ACCESS').toUpperCase();
ctx.font = 'bold 16px system-ui, sans-serif';
const alW = ctx.measureText(alTxt).width + 24;
const alX = (SIDEBAR_W - alW) / 2;
const alY = avY + avSize + 16;
roundRect(ctx, alX, alY, alW, 30, 7);
ctx.fillStyle = p.accent;
ctx.fill();
ctx.fillStyle = p.bg1;
ctx.fillText(alTxt, alX + 12, alY + 21);
// valid thru under access level
if (data.validThru) {
ctx.fillStyle = p.muted;
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('VALID THRU', SIDEBAR_W / 2, alY + 52);
ctx.fillStyle = p.accent;
ctx.font = 'bold 20px system-ui, sans-serif';
ctx.fillText(data.validThru, SIDEBAR_W / 2, alY + 76);
ctx.textAlign = 'left';
}
// ── Right zone ────────────────────────────────────────────────────────
const RX = SIDEBAR_W + 28;
const RMaxW = qrX - RX - 20;
let ry = 52;
// Park name + label
ctx.fillStyle = p.accent;
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.fillText(`🏟️ ${(data.issuerName || 'SKATEPARK').toUpperCase()}`, RX, ry);
ry += 6;
ctx.fillStyle = p.muted;
ctx.font = '12px system-ui, sans-serif';
ctx.fillText('PARK ACCESS CARD', RX, ry + 14);
ry += 34;
// Member name
ctx.fillStyle = p.text;
ctx.font = 'bold 50px system-ui, sans-serif';
fillTextFit(ctx, (data.recipientName || 'MEMBER').toUpperCase(), RX, ry + 46, RMaxW);
ry += 60;
// Divider
ctx.strokeStyle = p.accent;
ctx.globalAlpha = 0.2;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(RX, ry); ctx.lineTo(RX + RMaxW, ry); ctx.stroke();
ctx.globalAlpha = 1;
ry += 22;
// Issued by
ctx.fillStyle = p.muted;
ctx.font = '13px system-ui, sans-serif';
ctx.fillText('ISSUED BY PARK OWNER · NOSTR-SIGNED', RX, ry);
ry += 26;
// Full npub
ctx.fillStyle = p.accent;
ctx.font = '14px monospace';
ctx.fillText(data.npub.slice(0, 44), RX, ry);
ry += 20;
ctx.fillText(data.npub.slice(44), RX, ry);
}
// ─── 6. Bottom bar ───────────────────────────────────────────────────────
ctx.fillStyle = p.muted;
ctx.globalAlpha = 0.18;
ctx.fillRect(0, CARD_H - 36, CARD_W, 36);
ctx.globalAlpha = 1;
ctx.fillStyle = p.muted;
ctx.font = '13px system-ui, sans-serif';
ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', 32, CARD_H - 13);
ctx.textAlign = 'right';
ctx.fillText(data.memberYear, CARD_W - 32, CARD_H - 13);
ctx.textAlign = 'left';
return canvas.toDataURL('image/png');
}
// ─── Nostr kinds ──────────────────────────────────────────────────────────────
export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider
export const SKILL_BADGE_KIND = 1293; // Witness awards skill badge to rider
// ─── Themes ───────────────────────────────────────────────────────────────────
const THEMES = [
{ id: 'thug', label: 'THUG', emoji: '🔴', desc: 'Black + Neon Red' },
{ id: 'lime', label: 'LIME', emoji: '🟢', desc: 'Black + Neon Lime' },
{ id: 'chrome', label: 'CHROME', emoji: '⚪', desc: 'Steel & Silver' },
{ id: 'wood', label: 'WOOD', emoji: '🟤', desc: 'Woodgrain Classic' },
{ id: 'midnight', label: 'MIDNIGHT', emoji: '🔵', desc: 'Deep Blue' },
{ id: 'sunset', label: 'SUNSET', emoji: '🟠', desc: 'Orange & Pink' },
] as const;
type ThemeId = typeof THEMES[number]['id'];
// ─── Skill badges ─────────────────────────────────────────────────────────────
const SKILL_OPTIONS = [
{ id: 'kickflip', label: 'Kickflip', icon: '🛹' },
{ id: 'heelflip', label: 'Heelflip', icon: '👟' },
{ id: 'switch', label: 'Switch', icon: '🔄' },
{ id: 'nollie', label: 'Nollie', icon: '⬆️' },
{ id: 'fakie', label: 'Fakie', icon: '↩️' },
{ id: 'grind', label: 'Grinds', icon: '⚡' },
{ id: 'slide', label: 'Slides', icon: '➡️' },
{ id: 'manual', label: 'Manuals', icon: '🎯' },
{ id: 'vert', label: 'Vert', icon: '🏄' },
{ id: 'bowl', label: 'Bowl', icon: '🥣' },
{ id: 'street', label: 'Street', icon: '🏙️' },
{ id: 'park', label: 'Park', icon: '🌿' },
{ id: 'film', label: 'Filmer', icon: '🎥' },
{ id: 'diy', label: 'DIY Builder', icon: '🔨' },
] as const;
const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function shortNpub(pubkey: string) {
if (!pubkey) return '—';
const npub = nip19.npubEncode(pubkey);
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
}
function fullNpub(pubkey: string) {
if (!pubkey) return '';
return nip19.npubEncode(pubkey);
}
function memberSince(createdAt: number) {
return new Date(createdAt * 1000).getFullYear().toString();
}
// Verification URL — scanning this on a phone opens the profile on skate-spots.org
function verifyUrl(pubkey: string) {
if (!pubkey) return 'https://skate-spots.org';
return `https://skate-spots.org/${nip19.npubEncode(pubkey)}`;
}
// ─── Badge award event type ───────────────────────────────────────────────────
interface BadgeAward {
witnessName: string;
witnessPubkey: string;
skill: string;
spotName?: string;
timestamp: number;
}
// ─── Card data shape ──────────────────────────────────────────────────────────
interface CardData {
type: 'rider' | 'park';
theme: ThemeId;
holographic: boolean;
// Rider
name: string;
nip05?: string;
avatar?: string;
pubkey: string;
npub: string;
style: string;
skills: string[];
sponsors: string;
ridesFor: string;
showSpots: boolean;
showZaps: boolean;
showEvents: boolean;
spotsAdded: number;
zapsReceived: number;
eventsCreated: number;
memberYear: string;
receivedBadges: BadgeAward[];
// Park
issuerName: string;
recipientName: string;
accessLevel: string;
validThru: string;
}
// ─── Hook: fetch skill badges received by a pubkey ────────────────────────────
function useReceivedBadges(pubkey: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['skill-badges', pubkey],
queryFn: async (c) => {
if (!pubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query(
[{ kinds: [SKILL_BADGE_KIND], '#p': [pubkey], limit: 50 }],
{ signal },
);
return events.filter(e =>
e.tags.some(([k]) => k === 'p') &&
e.tags.some(([k]) => k === 'skill')
);
},
enabled: !!pubkey,
});
}
// ─── Hook: fetch author name for a pubkey ─────────────────────────────────────
function useWitnessName(pubkey: string) {
const author = useAuthor(pubkey);
return author.data?.metadata?.name ?? genUserName(pubkey);
}
// ─── Card visual: Rider front ─────────────────────────────────────────────────
// Layout is STRICTLY pixel-fixed to match the 340×214px card container.
// No flex-1 / flex-grow — every row has a fixed pixel height.
function RiderFront({ d }: { d: CardData }) {
const witnessedBadges = d.receivedBadges.slice(0, 2);
const qrUrl = verifyUrl(d.pubkey);
return (
// absolute inset — fills the card exactly
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* ── Row 1: header (26px) ── */}
<div className="flex items-start justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Skater License</div>
</div>
<div className="text-right">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
{shortNpub(d.pubkey)}
</div>
<div className="text-[5px] card-muted mt-[1px]">Since {d.memberYear}</div>
</div>
</div>
{/* ── Row 2: main body (118px) ── */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Avatar */}
<div className="flex-shrink-0 flex flex-col gap-[4px]">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
)}
</div>
{/* Stats under avatar */}
<div className="space-y-[2px]">
{d.showSpots && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">Spots</span>
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
</div>
)}
{d.showZaps && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">⚡</span>
<span className="text-[7px] font-black card-accent2 leading-none">{d.zapsReceived}</span>
</div>
)}
{d.showEvents && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">Evts</span>
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
</div>
)}
</div>
</div>
{/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</div>
{d.nip05 && (
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div>
)}
{d.style && (
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent w-fit">
{d.style}
</span>
)}
{d.ridesFor && (
<div className="text-[5.5px] card-muted truncate leading-none">🏪 {d.ridesFor}</div>
)}
{d.sponsors && (
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
)}
{/* Skills */}
<div className="flex flex-wrap gap-[2px] mt-[2px]">
{d.skills.slice(0, 4).map(sk => {
const s = SKILL_OPTIONS.find(o => o.id === sk);
return s ? (
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
{s.icon} {s.label}
</span>
) : null;
})}
{witnessedBadges.map((b, i) => {
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
return s ? (
<span
key={`w${i}`}
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none"
style={{ background: 'rgba(255,215,0,0.2)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
>
⭐ {s.label}
</span>
) : null;
})}
</div>
</div>
{/* QR placeholder — real QR renders in exported PNG */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<div className="rounded-[3px]" style={{ background: '#fff', padding: '3px', width: '44px', height: '44px' }}>
<div style={{ width: '38px', height: '38px', display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '1px' }}>
{Array.from({ length: 25 }).map((_, i) => (
<div key={i} style={{ background: [0,1,2,5,6,7,10,14,17,18,19,22,23,24].includes(i) ? '#000' : '#fff' }} />
))}
</div>
</div>
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">Scan to verify</div>
</div>
</div>
{/* ── Bottom bar: npub + website (absolute at bottom) ── */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5px] font-mono" style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shortNpub(d.pubkey)}
</span>
</div>
</div>
);
}
// ─── Card visual: Park Access front ──────────────────────────────────────────
function ParkFront({ d }: { d: CardData }) {
const qrUrl = verifyUrl(d.pubkey);
return (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* Row 1: header */}
<div className="flex items-center justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
🏟️ {d.issuerName || 'Skatepark'}
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Access Pass</div>
</div>
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
<span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
</div>
</div>
{/* Row 2: main body */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Avatar */}
<div className="flex-shrink-0">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
)}
</div>
</div>
{/* Identity */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || 'Member Name'}</div>
<div className="text-[5.5px] card-muted font-mono leading-none truncate">{shortNpub(d.pubkey)}</div>
<div className="text-[5.5px] card-muted leading-none">Issued by park owner · Nostr-signed</div>
{d.validThru && (
<div className="flex items-center gap-1 mt-[3px]">
<span className="text-[5.5px] card-muted uppercase">Valid thru</span>
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
</div>
)}
</div>
{/* QR placeholder */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<div className="rounded-[3px]" style={{ background: '#fff', padding: '3px', width: '44px', height: '44px' }}>
<div style={{ width: '38px', height: '38px', display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '1px' }}>
{Array.from({ length: 25 }).map((_, i) => (
<div key={i} style={{ background: [0,1,2,5,6,7,10,14,17,18,19,22,23,24].includes(i) ? '#000' : '#fff' }} />
))}
</div>
</div>
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">Scan to verify</div>
</div>
</div>
{/* Bottom bar */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<div className="flex items-center gap-1">
<Shield className="card-muted" style={{ width: '8px', height: '8px', opacity: 0.5 }} />
<span className="card-muted text-[5px] uppercase tracking-widest">Nostr-verified</span>
</div>
</div>
</div>
);
}
// ─── Main card wrapper ────────────────────────────────────────────────────────
function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
const themeClass = `card-theme-${d.theme}`;
const holoClass = d.holographic ? 'rider-card-holo' : '';
return (
<div
ref={printRef}
className={cn('rider-card', themeClass, holoClass, 'select-none')}
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif" }}
>
{/* Top color stripe */}
<div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />
{d.type === 'rider' && <RiderFront d={d} />}
{d.type === 'park' && <ParkFront d={d} />}
</div>
);
}
// ─── Received badges panel ────────────────────────────────────────────────────
function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
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">No badges yet. Skate in front of someone and ask them to witness your tricks!</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 ? 'Show less' : `Show ${badgeEvents.length - 3} more`}
</button>
)}
</div>
);
}
function BadgeEventRow({ event }: { event: NostrEvent }) {
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
const spot = event.tags.find(([k]) => k === 'spot')?.[1];
const witnessName = useWitnessName(event.pubkey);
const witnessAuthor = useAuthor(event.pubkey);
const witnessAvatar = witnessAuthor.data?.metadata?.picture;
return (
<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">
Witnessed by <span className="text-yellow-400 font-medium">{witnessName}</span>
{spot && <> 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() {
useSeoMeta({
title: 'Skater Card — SkateSpots',
description: 'Create your printable SkateSpots Skater License or Park Access Card.',
});
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey ?? '');
const metadata = author.data?.metadata;
const { data: stats } = useRiderStats(user?.pubkey);
const { mutate: publishEvent } = useNostrPublish();
const { toast } = useToast();
const printRef = useRef<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'>('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);
// Park fields
const [issuerName, setIssuerName] = useState('');
const [recipientName, setRecipientName] = useState('');
const [recipientNpub, setRecipientNpub] = useState('');
const [accessLevel, setAccessLevel] = useState('Full Access');
const [validThru, setValidThru] = useState('');
const [copied, setCopied] = useState(false);
const [rendering, setRendering] = useState(false);
const recipientPubkey = (() => {
if (!recipientNpub.trim()) return user?.pubkey ?? '';
try {
const d = nip19.decode(recipientNpub.trim());
return d.type === 'npub' ? d.data : (user?.pubkey ?? '');
} catch { return user?.pubkey ?? ''; }
})();
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
const cardData: CardData = {
type: cardType, theme, holographic,
name: cardType === 'rider' ? displayName : recipientName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
pubkey: cardPubkey,
npub: cardPubkey ? fullNpub(cardPubkey) : '',
style, skills, sponsors, ridesFor,
showSpots, showZaps, showEvents,
spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0,
eventsCreated: stats?.eventsCreated ?? 0,
memberYear: metadata?.created_at
? memberSince(metadata.created_at as number)
: new Date().getFullYear().toString(),
receivedBadges,
issuerName, recipientName, accessLevel, validThru,
};
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,
issuerName: cardData.issuerName, recipientName: cardData.recipientName,
accessLevel: cardData.accessLevel, validThru: cardData.validThru,
});
} catch (err) { console.error('Card render failed:', err); return null; }
finally { setRendering(false); }
}, [cardData]);
const handlePrint = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
const w = Math.round(85.6 * 2.835);
const h = Math.round(54 * 2.835);
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`);
if (!win) { toast({ title: 'Popup blocked', description: 'Allow popups for this site.', variant: 'destructive' }); return; }
win.document.write(`<!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]);
const handleDownload = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
const a = document.createElement('a');
a.href = dataUrl;
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
a.click();
}, [renderCardToImage, cardData.npub, toast]);
const handleCopyNpub = useCallback(() => {
navigator.clipboard.writeText(npub);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [npub]);
const handleShare = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
const res = await fetch(dataUrl);
const blob = await res.blob();
const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' });
if (navigator.share && navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ title: `${cardData.name}'s SkateSpots Card`, text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/${cardData.npub}`, files: [file] });
return;
} catch { /* fall through */ }
}
await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`);
toast({ title: '🔗 Link copied!' });
}, [renderCardToImage, cardData.name, cardData.npub, toast]);
const handlePublishParkCard = useCallback(() => {
if (!user || !recipientPubkey) { toast({ title: "Enter the rider's npub", variant: 'destructive' }); return; }
const tags: string[][] = [
['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`],
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
['issuer', issuerName], ['access-level', accessLevel], ['theme', theme],
];
if (validThru) tags.push(['valid-thru', validThru]);
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
onSuccess: () => toast({ title: '✅ Park Access Card published!' }),
onError: (e) => toast({ title: 'Publish failed', description: String(e), variant: 'destructive' }),
});
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast]);
const toggleSkill = (id: string) =>
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
// ── Logged out ────────────────────────────────────────────────────────────
if (!user) {
return (
<Layout>
<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">
Your verifiable skater identity. Nostr-signed, print-ready, impossible to fake.
</p>
<p className="text-gray-600 text-sm mb-8 max-w-xs">
Earn witnessed skill badges from other skaters. Get park access passes. Share your card anywhere.
</p>
<LoginArea className="justify-center" />
</div>
</Layout>
);
}
return (
<Layout>
<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">Nostr-signed · Print-ready · QR-verified</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: '🪪 My Card' },
{ id: 'badges', label: `🏅 Badges${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
{ id: 'award', label: '⭐ Award' },
] 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 ? 'Rendering…' : 'Share Card'}
</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" />
Save PNG
</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" />
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 ? 'Copied!' : 'Copy ID'}
</Button>
</div>
</div>
{/* ── Card type toggle ── */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2">Card Type</p>
<div className="grid grid-cols-2 gap-2">
{([
{ id: 'rider', label: 'Skater License', sub: 'Your personal rider identity', icon: User },
{ id: 'park', label: 'Park Access', sub: 'Issue passes as a park owner', icon: Building2 },
] as const).map(({ id, label, sub, icon: Icon }) => (
<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" /> Theme
</p>
<div className="grid grid-cols-6 gap-2">
{THEMES.map(t => (
<button
key={t.id}
onClick={() => setTheme(t.id)}
className={cn(
'aspect-square rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-0.5',
`card-theme-${t.id}`,
theme === t.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">{t.emoji}</span>
<span className="text-[8px] font-black uppercase opacity-80">{t.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">✨ Holographic Shimmer</div>
<div className="text-xs text-gray-500">Animated foil effect</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">Card Details</p>
{/* Style */}
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Skate Style</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">Rides For</Label>
<Input
placeholder="Local shop…"
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">Sponsors</Label>
<Input
placeholder="Brands…"
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">Show on Card</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'Spots', value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots },
{ label: 'Zaps ⚡', value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps },
{ label: 'Events', value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents },
].map(({ label, value, state, set }) => (
<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">Skills</Label>
<span className="text-[10px] text-gray-600">{skills.length}/6 selected</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">
⭐ Gold badges on your card come from other skaters witnessing you — see the <button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">Badges tab</button>.
</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">Park owners only.</span>{' '}
Issue this using the same Nostr key that published your park listing. Anyone can scan the QR and verify the signature on-chain — no central authority needed.
</div>
</div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">Park Details</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Park Name</Label>
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
placeholder="The Berrics…" 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">Rider Name</Label>
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
placeholder="Name on card" 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">Rider's Nostr ID (npub)</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">Access Level</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">Valid Thru</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>
<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" />
Publish to Nostr
</Button>
</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">
These badges were witnessed and signed by other skaters on Nostr. They appear as gold ⭐ chips on your card.
</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">Want to earn more? Skate in front of someone and ask them to witness your trick.</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" />
Award a Badge to Someone Else
</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">You saw it happen</div>
<div className="text-xs text-gray-400 mt-0.5">Sign the proof on Nostr. It becomes part of their permanent record — cryptographically tied to your key.</div>
</div>
</div>
{/* Inline award form — the dialog was removed as dead code */}
<AwardBadgeInline issuerPubkey={user.pubkey} onSuccess={() => setPageTab('badges')} />
</div>
)}
</div>
</Layout>
);
}
// ─── Inline award form (used on the Award tab) ────────────────────────────────
function AwardBadgeInline({ issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) {
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: 'Add rider npub + at least one skill', variant: 'destructive' });
return;
}
let recipientHex = '';
try {
const decoded = nip19.decode(recipientNpub.trim());
if (decoded.type !== 'npub') throw new Error();
recipientHex = decoded.data;
} catch {
toast({ title: 'Invalid npub', variant: 'destructive' });
return;
}
const tags: string[][] = [
['p', recipientHex], ['t', 'skill-badge'],
['alt', `Skill badge awarded: ${selectedSkills.join(', ')}`],
...selectedSkills.map(s => ['skill', s]),
];
if (spotName.trim()) tags.push(['spot', spotName.trim()]);
publishEvent({ kind: SKILL_BADGE_KIND, content: '', tags }, {
onSuccess: () => {
toast({ title: '🏅 Badge awarded!', description: `${selectedSkills.join(', ')} sent.` });
setRecipientNpub(''); setSelectedSkills([]); setSpotName('');
onSuccess();
},
onError: (e) => toast({ title: 'Failed', description: String(e), variant: 'destructive' }),
});
};
return (
<div className="space-y-5">
<div>
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rider's Nostr ID (npub)</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">Skills you witnessed</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">Where did you see it? <span className="normal-case text-gray-600">(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 ? 'Signing on Nostr…' : 'Award Badge'}
</Button>
</div>
);
}