SkateSpots/src/pages/RiderCardPage.tsx
shakespeare.diy ebbf2d6326 fix: remove orphaned canvas code from RiderCardPage causing ctx/Printer is not defined crash
The drawCardToCanvas function had an old version of the card rendering logic
that leaked outside the function body (lines 505-731 were orphaned code).
This code referenced local variables (ctx, p, data, qrX, qrSize, PAD, etc.)
that only existed inside the function, causing ReferenceError at module load time
and crashing the entire app with a blank screen.

Also added missing lucide-react imports (Printer, Loader2, Share2, Download,
Check, Copy, Award, Zap, Info, Palette, Shield, ChevronUp, ChevronDown) that
were used in the JSX but not imported, causing the secondary Printer is not
defined ReferenceError.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-16 17:02:59 +07:00

1703 lines
73 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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useRiderStats } from '@/hooks/useRiderStats';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useNostr } from '@/hooks/useNostr';
import { useQuery } from '@tanstack/react-query';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Printer,
Loader2,
Share2,
Download,
Check,
Copy,
Award,
Zap,
Info,
Palette,
Shield,
ChevronUp,
ChevronDown,
} from 'lucide-react';
// ─── Card canvas renderer ─────────────────────────────────────────────────────
// Draws the card entirely via Canvas 2D API — 100% reliable export,
// no CSS capture, no scaling issues, no background-stripping.
// Card is 856×540px (10× credit-card mm, high-res for printing).
const CARD_W = 856;
const CARD_H = 540;
interface CanvasCardData {
type: 'rider' | 'park';
themeId: string;
name: string;
nip05?: string;
avatarUrl?: string;
pubkey: string;
npub: string;
style: string;
skills: string[];
sponsors: string;
ridesFor: string;
showSpots: boolean;
showZaps: boolean;
showEvents: boolean;
spotsAdded: number;
zapsReceived: number;
eventsCreated: number;
memberYear: string;
receivedBadges: BadgeAward[];
// Park
issuerName: string;
recipientName: string;
accessLevel: string;
validThru: string;
}
// Theme palette for canvas drawing
const THEME_PALETTE: Record<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>
);
}
// ─── Award Badge Dialog ───────────────────────────────────────────────────────
interface AwardBadgeDialogProps {
open: boolean;
onClose: () => void;
issuerPubkey: string;
}
function AwardBadgeDialog({ open, onClose, issuerPubkey }: AwardBadgeDialogProps) {
const { mutate: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast();
const [recipientNpub, setRecipientNpub] = useState('');
const [selectedSkills, setSelectedSkills] = useState<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: 'Fill in rider npub + at least one skill', variant: 'destructive' });
return;
}
let recipientHex = '';
try {
const decoded = nip19.decode(recipientNpub.trim());
if (decoded.type !== 'npub') throw new Error('not npub');
recipientHex = decoded.data;
} catch {
toast({ title: 'Invalid npub', description: 'Paste a valid npub1… address', variant: 'destructive' });
return;
}
const tags: string[][] = [
['p', recipientHex],
['t', 'skill-badge'],
['alt', `Skill badge awarded: ${selectedSkills.join(', ')}`],
...selectedSkills.map(s => ['skill', s]),
];
if (spotName.trim()) tags.push(['spot', spotName.trim()]);
publishEvent(
{ kind: SKILL_BADGE_KIND, content: '', tags },
{
onSuccess: () => {
toast({ title: '🏅 Badge awarded!', description: `${selectedSkills.join(', ')} sent to rider.` });
setRecipientNpub('');
setSelectedSkills([]);
setSpotName('');
onClose();
},
onError: (e) => toast({ title: 'Failed', description: String(e), variant: 'destructive' }),
},
);
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-black border-gray-800 max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Award className="h-5 w-5 text-yellow-400" />
Award a Skill Badge
</DialogTitle>
<DialogDescription className="text-gray-400">
You saw it happen. Sign the proof on Nostr — it becomes part of their permanent record.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Recipient */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">
Rider's Nostr Address (npub)
</Label>
<Input
value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)}
placeholder="npub1…"
className="bg-black/50 border-gray-700 font-mono text-xs"
/>
</div>
{/* Skills */}
<div>
<Label className="text-xs 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 py-1 rounded-full text-[11px] font-bold border transition-all',
selectedSkills.includes(s.id)
? 'bg-yellow-500/20 border-yellow-400 text-yellow-300'
: 'bg-black/40 border-gray-700 text-gray-400 hover:border-gray-500'
)}
>
{s.icon} {s.label}
</button>
))}
</div>
</div>
{/* Spot name */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">
Where did you witness it? (optional)
</Label>
<Input
value={spotName}
onChange={e => setSpotName(e.target.value)}
placeholder="e.g. MACBA, The Berrics, Burnside…"
className="bg-black/50 border-gray-700 text-sm"
/>
</div>
<div className="p-3 rounded-xl bg-yellow-500/5 border border-yellow-500/20 text-xs text-gray-400">
<span className="font-bold text-yellow-400">🔐 Signed proof.</span>{' '}
This badge is a signed message on the Nostr network — cryptographically tied to your key.
The rider can show it with your name as witness, and anyone can verify it's real.
</div>
<Button
onClick={handleAward}
disabled={isPending || selectedSkills.length === 0 || !recipientNpub.trim()}
className="w-full bg-yellow-500 hover:bg-yellow-400 text-black font-black uppercase gap-2"
>
<Award className="h-4 w-4" />
{isPending ? 'Signing…' : 'Award Badge'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ─── 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) : '';
// Received badges for the logged-in user
const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? '');
// Parse badge events into BadgeAward objects
const receivedBadges: BadgeAward[] = receivedBadgeEvents.flatMap(event => {
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
const spot = event.tags.find(([k]) => k === 'spot')?.[1];
return skills.map(skill => ({
skill,
witnessName: genUserName(event.pubkey), // simplified for card visual
witnessPubkey: event.pubkey,
spotName: spot,
timestamp: event.created_at,
}));
});
// ── Form state ──────────────────────────────────────────────────────────────
const [cardType, setCardType] = useState<'rider' | 'park'>('rider');
const [theme, setTheme] = useState<ThemeId>('thug');
const [holographic, setHolographic] = useState(true);
const [showAwardDialog, setShowAwardDialog] = useState(false);
// 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);
// ── Parse recipientNpub → pubkey ─────────────────────────────────────────
const recipientPubkey = (() => {
if (!recipientNpub.trim()) return user?.pubkey ?? '';
try {
const d = nip19.decode(recipientNpub.trim());
return d.type === 'npub' ? d.data : (user?.pubkey ?? '');
} catch {
return user?.pubkey ?? '';
}
})();
// ── Computed card data ───────────────────────────────────────────────────────
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
const cardData: CardData = {
type: cardType,
theme,
holographic,
name: cardType === 'rider' ? displayName : recipientName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
pubkey: cardPubkey,
npub: cardPubkey ? fullNpub(cardPubkey) : '',
style,
skills,
sponsors,
ridesFor,
showSpots, showZaps, showEvents,
spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0,
eventsCreated: stats?.eventsCreated ?? 0,
memberYear: metadata?.created_at
? memberSince(metadata.created_at as number)
: new Date().getFullYear().toString(),
receivedBadges,
issuerName,
recipientName,
accessLevel,
validThru,
};
// ── Render card via Canvas 2D API → returns a PNG data URL ──────────────
const renderCardToImage = useCallback(async (): Promise<string | null> => {
setRendering(true);
try {
const dataUrl = await drawCardToCanvas({
type: cardData.type,
themeId: cardData.theme,
name: cardData.name,
nip05: cardData.nip05,
avatarUrl: cardData.avatar,
pubkey: cardData.pubkey,
npub: cardData.npub,
style: cardData.style,
skills: cardData.skills,
sponsors: cardData.sponsors,
ridesFor: cardData.ridesFor,
showSpots: cardData.showSpots,
showZaps: cardData.showZaps,
showEvents: cardData.showEvents,
spotsAdded: cardData.spotsAdded,
zapsReceived: cardData.zapsReceived,
eventsCreated: cardData.eventsCreated,
memberYear: cardData.memberYear,
receivedBadges: cardData.receivedBadges,
issuerName: cardData.issuerName,
recipientName: cardData.recipientName,
accessLevel: cardData.accessLevel,
validThru: cardData.validThru,
});
return dataUrl;
} catch (err) {
console.error('Card render failed:', err);
return null;
} finally {
setRendering(false);
}
}, [cardData]);
// ── Print: render to image, open print window with the image ─────────────
const handlePrint = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) {
toast({ title: 'Could not render card', description: 'Try again in a moment.', variant: 'destructive' });
return;
}
// 85.6mm × 54mm in points (1mm ≈ 2.835pt)
const w = Math.round(85.6 * 2.835);
const h = Math.round(54 * 2.835);
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`);
if (!win) {
toast({ title: 'Popup blocked', description: 'Allow popups for this site then try again.', variant: 'destructive' });
return;
}
win.document.write(`<!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}" />
<script>
window.onload = function() {
window.focus();
window.print();
};
</script>
</body>
</html>`);
win.document.close();
}, [renderCardToImage, toast]);
// ── Download card as PNG ──────────────────────────────────────────────────
const handleDownload = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) {
toast({ title: 'Could not render card', variant: 'destructive' });
return;
}
const a = document.createElement('a');
a.href = dataUrl;
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
a.click();
}, [renderCardToImage, cardData.licenseId, toast]);
const handleCopyNpub = useCallback(() => {
navigator.clipboard.writeText(npub);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [npub]);
// ── Share card digitally ──────────────────────────────────────────────────
const handleShare = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) {
toast({ title: 'Could not render card', variant: 'destructive' });
return;
}
// Convert data URL to File for Web Share API
const res = await fetch(dataUrl);
const blob = await res.blob();
const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' });
if (navigator.share && navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({
title: `${cardData.name}'s SkateSpots Card`,
text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/${cardData.npub}`,
files: [file],
});
return;
} catch {
// user cancelled or share failed — fall through to copy
}
}
// Fallback: copy profile link to clipboard
const link = `https://skate-spots.org/${cardData.npub}`;
await navigator.clipboard.writeText(link);
toast({ title: '🔗 Link copied!', description: 'Share it anywhere — the link opens your card profile.' });
}, [renderCardToImage, cardData.name, cardData.npub, toast]);
const handlePublishParkCard = useCallback(() => {
if (!user) return;
if (!recipientPubkey) {
toast({ title: "Enter the rider's npub", variant: 'destructive' });
return;
}
const tags: string[][] = [
['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`],
['p', recipientPubkey],
['t', 'skatepark-access'],
['alt', 'Skatepark Access Card'],
['issuer', issuerName],
['access-level', accessLevel],
['theme', theme],
];
if (validThru) tags.push(['valid-thru', validThru]);
publishEvent(
{ kind: PARK_ACCESS_CARD_KIND, content: '', tags },
{
onSuccess: () => toast({ title: '✅ Park Access Card published!', description: 'The rider can now verify it.' }),
onError: (e) => toast({ title: 'Publish failed', description: String(e), variant: 'destructive' }),
},
);
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast]);
const toggleSkill = (id: string) =>
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
// ── Render: logged out ────────────────────────────────────────────────────
if (!user) {
return (
<Layout>
<div className="container max-w-lg px-4 py-20 text-center">
<div className="text-6xl mb-4">🛹</div>
<h1 className="text-3xl font-black uppercase mb-3 neon-red">Rider Card</h1>
<p className="text-gray-400 mb-6">Log in to generate your personal Rider Card.</p>
<LoginArea className="justify-center" />
</div>
</Layout>
);
}
return (
<Layout>
<div className="container max-w-5xl px-4 py-10">
{/* Page header */}
<div className="mb-8">
<h1 className="text-4xl font-black uppercase mb-2">
<span className="neon-red">SKATE</span>
<span className="text-white"> CARDS</span>
</h1>
<p className="text-gray-400 text-sm">
Print-ready credit-card size (85.6 × 54 mm). QR-verified via Nostr.
</p>
</div>
<div className="grid lg:grid-cols-2 gap-8">
{/* Left: Card preview */}
<div className="flex flex-col items-center gap-5">
<RiderCardFront d={cardData} printRef={printRef} />
{/* Primary actions */}
<div className="flex gap-2 flex-wrap justify-center w-full">
<Button
onClick={handlePrint}
disabled={rendering}
className="bg-[#ff0040] hover:bg-[#cc0030] gap-2 font-black uppercase flex-1 min-w-[130px]"
>
{rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Printer className="h-4 w-4" />}
{rendering ? 'Rendering…' : 'Print'}
</Button>
<Button
onClick={handleShare}
disabled={rendering}
className="bg-[#ccff00] hover:bg-[#b8e600] text-black gap-2 font-black uppercase flex-1 min-w-[130px]"
>
{rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Share
</Button>
<Button
variant="outline"
onClick={handleDownload}
disabled={rendering}
className="gap-2 border-gray-700 hover:border-gray-500 flex-1 min-w-[130px]"
>
{rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Save PNG
</Button>
</div>
{/* Secondary actions */}
<div className="flex gap-2 flex-wrap justify-center w-full">
<Button
variant="outline"
onClick={handleCopyNpub}
className="gap-2 border-gray-700 text-xs"
>
{copied ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
{copied ? 'Copied!' : 'Copy npub'}
</Button>
<Button
variant="outline"
onClick={() => setShowAwardDialog(true)}
className="gap-2 border-yellow-500/40 text-yellow-400 hover:bg-yellow-500/10 text-xs"
>
<Award className="h-3 w-3" />
Award Badge
</Button>
{cardType === 'park' && (
<Button
variant="outline"
onClick={handlePublishParkCard}
className="gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs"
>
<Zap className="h-3 w-3" />
Publish to Nostr
</Button>
)}
</div>
<p className="text-gray-600 text-xs text-center max-w-xs">
<strong className="text-gray-500">Share</strong> uses your device's share sheet (WhatsApp, iMessage, etc.) or copies the link. <strong className="text-gray-500">Save PNG</strong> downloads for print shops.
</p>
</div>
{/* Right: Controls */}
<div className="space-y-5">
{/* Card type */}
<div>
<Label className="mb-2 block text-xs uppercase tracking-widest text-gray-500">Card Type</Label>
<div className="grid grid-cols-2 gap-2">
{([
{ id: 'rider', label: 'Skater License', icon: User },
{ id: 'park', label: 'Park Access', icon: Building2 },
] as const).map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setCardType(id)}
className={cn(
'p-3 rounded-xl border text-xs font-bold uppercase flex flex-col items-center gap-1 transition-all',
cardType === id
? 'bg-[#ff0040]/20 border-[#ff0040] text-white'
: 'bg-black/40 border-gray-700 text-gray-400 hover:border-gray-500'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
</div>
{/* Park access note */}
{cardType === 'park' && (
<div className="flex items-start gap-2 p-3 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 className="space-y-1.5">
<div>
<span className="font-bold text-blue-400">Park owners only.</span>{' '}
Park Access Cards should only be issued by the park's own Nostr key — the same key that published the park listing on SkateSpots.
</div>
<div>
<span className="font-bold text-gray-300">How verification works:</span>{' '}
The card is a signed message on the Nostr network (like a public post only you could have written).
Anyone can scan the QR, look up the event on any Nostr relay, and see which key signed it —
if that key matches the park's registered identity, the card is genuine.
No central authority needed.
</div>
<div className="text-gray-500">
Self-issued cards for private parks will show a mismatch warning — the park owner's key won't match.
</div>
</div>
</div>
)}
{/* Theme picker */}
<div>
<Label className="mb-2 flex items-center gap-2 text-xs uppercase tracking-widest text-gray-500">
<Palette className="h-3 w-3" /> Theme
</Label>
<div className="grid grid-cols-3 gap-2">
{THEMES.map(t => (
<button
key={t.id}
onClick={() => setTheme(t.id)}
className={cn(
'p-2.5 rounded-lg border text-xs font-bold transition-all',
`card-theme-${t.id}`,
theme === t.id ? 'ring-2 ring-white/40 scale-105' : 'opacity-70 hover:opacity-100'
)}
>
<div className="text-base mb-0.5">{t.emoji}</div>
<div className="text-[10px] font-black">{t.label}</div>
<div className="text-[9px] opacity-60">{t.desc}</div>
</button>
))}
</div>
</div>
{/* Holographic toggle */}
<div className="flex items-center justify-between p-3 rounded-xl bg-black/40 border border-gray-700">
<div>
<div className="text-sm font-bold">✨ Holographic Shimmer</div>
<div className="text-xs text-gray-500">Animated foil effect on screen</div>
</div>
<Switch checked={holographic} onCheckedChange={setHolographic} />
</div>
{/* ── RIDER fields ─────────────────────────────────────────── */}
{cardType === 'rider' && (
<div className="space-y-4">
{/* Style */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Skate Style</Label>
<Select value={style} onValueChange={setStyle}>
<SelectTrigger className="bg-black/50 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STYLES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Rides For shop */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">
Rides For (shop)
</Label>
<Input
placeholder="e.g. Familia Skateshop, Supra Skateboards…"
value={ridesFor}
onChange={e => setRidesFor(e.target.value)}
className="bg-black/50 border-gray-700 text-sm"
/>
<p className="text-[11px] text-gray-600 mt-1">
The local shop you represent. Different from sponsors.
</p>
</div>
{/* Sponsors */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">
Sponsors (brands/companies)
</Label>
<Input
placeholder="e.g. Baker, Vans, Independent…"
value={sponsors}
onChange={e => setSponsors(e.target.value)}
className="bg-black/50 border-gray-700 text-sm"
/>
</div>
{/* Stats toggles */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-2 block">Show Stats</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ label: `Spots (${stats?.spotsAdded ?? 0})`, state: showSpots, set: setShowSpots },
{ label: `Zaps (${stats?.zapsReceived ?? 0})`, state: showZaps, set: setShowZaps },
{ label: `Events (${stats?.eventsCreated ?? 0})`, state: showEvents, set: setShowEvents },
].map(({ label, state, set }) => (
<button
key={label}
onClick={() => set(!state)}
className={cn(
'p-2 rounded-lg border text-[10px] font-bold transition-all',
state
? 'bg-[#ff0040]/20 border-[#ff0040] text-white'
: 'bg-black/40 border-gray-700 text-gray-500'
)}
>
{label}
</button>
))}
</div>
</div>
{/* Skill badges (self-selected) */}
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-2 block">
Skills (self-claimed, up to 6)
</Label>
<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 py-1 rounded-full text-[11px] font-bold border transition-all',
skills.includes(s.id)
? 'bg-[#ff0040]/20 border-[#ff0040] text-white'
: 'bg-black/40 border-gray-700 text-gray-400 hover:border-gray-500 disabled:opacity-30'
)}
>
{s.icon} {s.label}
</button>
))}
</div>
<p className="text-[11px] text-gray-600 mt-1.5">
⭐ Gold badges on the card are <em>witnessed</em> by others — check the Badges tab.
</p>
</div>
</div>
)}
{/* ── PARK fields ──────────────────────────────────────────── */}
{cardType === 'park' && (
<div className="space-y-4">
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Park Name</Label>
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
placeholder="e.g. The Berrics, FDR Skatepark…" className="bg-black/50 border-gray-700" />
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Rider Name (on card)</Label>
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
placeholder="Full name on card" className="bg-black/50 border-gray-700" />
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">
Rider's Nostr Address (npub)
</Label>
<Input
value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)}
placeholder="npub1… — their Nostr identity ties the card to them"
className="bg-black/50 border-gray-700 font-mono text-xs"
/>
<p className="text-[11px] text-gray-600 mt-1">
Leave blank to issue to yourself. The card is signed by <em>your</em> key as the park owner.
</p>
</div>
<div>
<Label className="text-xs uppercase tracking-widest text-gray-500 mb-1.5 block">Access Level</Label>
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="bg-black/50 border-gray-700"><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-xs 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="e.g. 2026-12-31" className="bg-black/50 border-gray-700" />
</div>
<div className="p-3 rounded-xl bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-gray-400 space-y-2">
<div className="font-bold text-[#ccff00]">🔐 How the park verifies it's real</div>
<div>
When you publish this card, it's signed with your Nostr private key and broadcast to relays.
The park's staff can scan the QR on any printed card and instantly see:
</div>
<ul className="list-disc list-inside space-y-1 text-gray-500">
<li>Who issued it (your npub)</li>
<li>Who it was issued to (the rider's npub)</li>
<li>The access level and expiry</li>
<li>The exact timestamp it was signed</li>
</ul>
<div>
A rider can't fake this card — they'd need your private key to forge your signature.
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Award badge dialog */}
<AwardBadgeDialog
open={showAwardDialog}
onClose={() => setShowAwardDialog(false)}
issuerPubkey={user.pubkey}
/>
</Layout>
);
}