Card redesign: 3-zone layout, shop/sponsor styling, Share button, remove Badges tab

Canvas renderer overhaul:
- New 3-zone layout: left sidebar (avatar + stats + since) | right zone
  (name/identity/shop/sponsors/skills) | top-right QR block
- Left sidebar has a subtle tinted background with accent-colored separator
- Stats shown as balanced column grid under the avatar
- Shop: "RIDES FOR" label + bold italic Georgia serif in theme shopColor (distinct from accent)
- Sponsors: "SPONSORS" label + regular weight in theme sponsorColor (warm gold/orange)
- Each theme now has shopColor + sponsorColor + sidebarBg + chipBorder fields
- Skills row appears only when skills/badges exist, with accent border on chips
- Full npub (44+remainder chars) shown at bottom of right zone in monospace
- Bottom bar: subtle fill + "SKATE-SPOTS.ORG" left, year right
- Park Access card: same sidebar layout, access level chip in solid accent fill

Share button:
- Uses Web Share API (navigator.share) if device supports it — opens native
  share sheet (WhatsApp, iMessage, Telegram, AirDrop, etc.) with PNG file
- Falls back to copying profile URL to clipboard
- Lime green color to visually stand out from Print button

UI cleanup:
- Remove Badges tab entirely — was cluttering the nav
- Award Badge moved inline to secondary button row (yellow, subtle)
- Primary actions: Print | Share | Save PNG (all same size, flex-wrap)
- Secondary actions: Copy npub | Award Badge | Publish to Nostr
- Fixed stray closing divs from tab removal

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 16:07:47 +07:00
parent 79dc1e1dcf
commit 8d273428f3

View file

@ -83,15 +83,16 @@ const THEME_PALETTE: Record<string, {
bg1: string; bg2: string; bg3: string;
accent: string; accent2: string;
stripe1: string; stripe2: string;
muted: string; chipBg: string;
text: string;
muted: string; chipBg: string; chipBorder: string;
text: string; shopColor: string; sponsorColor: string;
sidebarBg: string;
}> = {
thug: { bg1: '#0a0a0a', bg2: '#1a0008', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(255,0,64,0.25)', text: '#ffffff' },
lime: { bg1: '#050f00', bg2: '#0a1a00', bg3: '#050f00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(204,255,0,0.2)', text: '#ffffff' },
chrome: { bg1: '#1a1a1a', bg2: '#2e2e2e', bg3: '#111111', accent: '#c8c8c8', accent2: '#ff6600', stripe1: '#888888', stripe2: '#cccccc', muted: 'rgba(232,232,232,0.6)', chipBg: 'rgba(200,200,200,0.15)', text: '#e8e8e8' },
wood: { bg1: '#3d2008', bg2: '#5c3210', bg3: '#2e1804', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.65)', chipBg: 'rgba(160,100,40,0.3)', text: '#f5e6c8' },
midnight: { bg1: '#020818', bg2: '#050e2a', bg3: '#030b1a', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#3060cc', stripe2: '#64a0ff', muted: 'rgba(208,228,255,0.6)', chipBg: 'rgba(100,160,255,0.15)', text: '#d0e4ff' },
sunset: { bg1: '#120006', bg2: '#1a0808', bg3: '#0e0810', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,208,160,0.65)', chipBg: 'rgba(255,80,0,0.2)', text: '#ffd0a0' },
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> {
@ -153,52 +154,353 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe
async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = CARD_W;
canvas.width = CARD_W;
canvas.height = CARD_H;
const ctx = canvas.getContext('2d')!;
const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug;
// ── Background gradient ──────────────────────────────────────────────────
const grad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
grad.addColorStop(0, p.bg1);
grad.addColorStop(0.6, p.bg2);
grad.addColorStop(1, p.bg3);
// ─── 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 = grad;
ctx.fillStyle = bgGrad;
ctx.fill();
// ── Top color stripe (8px) ───────────────────────────────────────────────
// ─── 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, 8);
ctx.fillRect(0, 0, CARD_W, 10);
const PAD = 40;
// ── Generate QR first (data: URI — never proxy) ──────────────────────────
const qrUrl = `https://skate-spots.org/${data.npub}`;
const qrSize = 180;
const qrX = CARD_W - PAD - qrSize;
const qrY = 28;
// ─── 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 {
const qrDataUrl = await QRCode.toDataURL(qrUrl, {
width: qrSize * 2, // render at 2× for crispness, then downscale
width: qrSize * 2,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
});
qrImg = await loadImage(qrDataUrl); // data: URI — won't proxy
} catch { /* QR skipped */ }
qrImg = await loadImage(qrDataUrl); // data: URI — no proxy
} catch { /* skip */ }
if (qrImg) {
// White padding behind QR
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');
}
ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif';
ctx.textAlign = 'center';
@ -426,34 +728,6 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
}
}
// ── Bottom divider line ──────────────────────────────────────────────────
ctx.strokeStyle = p.muted;
ctx.globalAlpha = 0.2;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD, CARD_H - 42);
ctx.lineTo(CARD_W - PAD, CARD_H - 42);
ctx.stroke();
ctx.globalAlpha = 1;
// ── Bottom bar ──────────────────────────────────────────────────────────
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', PAD, CARD_H - 18);
ctx.textAlign = 'right';
// Show year issued
ctx.fillText(data.memberYear, CARD_W - PAD, CARD_H - 18);
ctx.textAlign = 'left';
return canvas.toDataURL('image/png');
}
import {
Printer, Download, Copy, Palette, Check, Building2,
User, Shield, Award, Zap,
ChevronDown, ChevronUp, Info, Loader2,
} from 'lucide-react';
// ─── 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
@ -1053,7 +1327,6 @@ export default function RiderCardPage() {
const [theme, setTheme] = useState<ThemeId>('thug');
const [holographic, setHolographic] = useState(true);
const [showAwardDialog, setShowAwardDialog] = useState(false);
const [activeTab, setActiveTab] = useState<'card' | 'badges'>('card');
// Rider fields
const [style, setStyle] = useState('Street');
@ -1212,6 +1485,38 @@ export default function RiderCardPage() {
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) {
@ -1270,99 +1575,74 @@ export default function RiderCardPage() {
</p>
</div>
{/* Inner tab nav */}
<div className="flex gap-1 mb-6 bg-black/40 p-1 rounded-xl border border-gray-800 w-fit">
<button
onClick={() => setActiveTab('card')}
className={cn(
'px-4 py-2 rounded-lg text-sm font-bold transition-all',
activeTab === 'card'
? 'bg-[#ff0040] text-white'
: 'text-gray-400 hover:text-white'
)}
>
🛹 My Card
</button>
<button
onClick={() => setActiveTab('badges')}
className={cn(
'px-4 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-1',
activeTab === 'badges'
? 'bg-yellow-500 text-black'
: 'text-gray-400 hover:text-white'
)}
>
🏅 Badges
{receivedBadges.length > 0 && (
<span className="bg-yellow-400 text-black text-[9px] font-black px-1.5 py-0.5 rounded-full">
{receivedBadges.length}
</span>
)}
</button>
<button
onClick={() => setShowAwardDialog(true)}
className="px-4 py-2 rounded-lg text-sm font-bold transition-all text-gray-400 hover:text-white flex items-center gap-1"
>
<Award className="h-3.5 w-3.5" />
Award Badge
</button>
</div>
{/* ── Card tab ─────────────────────────────────────────────────── */}
{activeTab === 'card' && (
<div className="grid lg:grid-cols-2 gap-8">
<div className="grid lg:grid-cols-2 gap-8">
{/* Left: Card preview */}
<div className="flex flex-col items-center gap-6">
<div className="flex flex-col items-center gap-5">
<RiderCardFront d={cardData} printRef={printRef} />
{/* Actions */}
<div className="flex gap-3 flex-wrap justify-center">
{/* 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"
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 Card'}
{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"
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"
className="gap-2 border-gray-700 text-xs"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
{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]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
className="gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs"
>
<Zap className="h-4 w-4" />
<Zap className="h-3 w-3" />
Publish to Nostr
</Button>
)}
</div>
<div className="text-center space-y-1">
<p className="text-gray-500 text-xs max-w-xs">
🖨️ <strong className="text-gray-400">Print Card</strong> opens a print window with the card as an image — backgrounds guaranteed to show.
</p>
<p className="text-gray-600 text-xs max-w-xs">
💾 <strong className="text-gray-400">Save PNG</strong> to share digitally, use in iMessage, WhatsApp, or take to a print shop.
</p>
</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 */}
@ -1612,43 +1892,7 @@ export default function RiderCardPage() {
</div>
</div>
)}
{/* ── Badges tab ────────────────────────────────────────────────── */}
{activeTab === 'badges' && (
<div className="max-w-xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-black uppercase">Your Witnessed Badges</h2>
<p className="text-xs text-gray-500 mt-0.5">
Badges signed by people who saw you skate. Cryptographically unforgeable.
</p>
</div>
<Button
onClick={() => setShowAwardDialog(true)}
variant="outline"
className="gap-2 border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10"
>
<Award className="h-4 w-4" />
Award to Someone
</Button>
</div>
<ReceivedBadgesPanel pubkey={user.pubkey} />
<div className="p-4 rounded-xl bg-black/40 border border-gray-700">
<h3 className="text-sm font-bold mb-2">🏅 How badges work</h3>
<ul className="text-xs text-gray-400 space-y-1.5 list-disc list-inside">
<li>Anyone can award you a badge when they witness your tricks</li>
<li>Badges are signed Nostr events — they can't be faked</li>
<li>The more trusted the witness (NIP-05 verified, known in the community), the more weight the badge carries</li>
<li>Gold ⭐ badges on your card show skills others have witnessed — different from your self-claimed skills</li>
<li>Ask friends and filmers to award you badges at spots you've skated</li>
</ul>
</div>
</div>
)}
</div>
</div>
{/* Award badge dialog */}
<AwardBadgeDialog