diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx
index 8b0e6fc..20ea53a 100644
--- a/src/pages/RiderCardPage.tsx
+++ b/src/pages/RiderCardPage.tsx
@@ -83,15 +83,16 @@ const THEME_PALETTE: Record = {
- 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 {
@@ -153,52 +154,353 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe
async function drawCardToCanvas(data: CanvasCardData): Promise {
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 {
}
}
- // ── 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('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() {
- {/* Inner tab nav */}
-
-
-
-
-
-
- {/* ── Card tab ─────────────────────────────────────────────────── */}
- {activeTab === 'card' && (
-
+
{/* Left: Card preview */}
-
+
- {/* Actions */}
-
+ {/* Primary actions */}
+
+
+
+
+ {/* Secondary actions */}
+
+
{cardType === 'park' && (
)}
-
-
- 🖨️ Print Card opens a print window with the card as an image — backgrounds guaranteed to show.
-
-
- 💾 Save PNG to share digitally, use in iMessage, WhatsApp, or take to a print shop.
-
-
+
+ Share uses your device's share sheet (WhatsApp, iMessage, etc.) or copies the link. Save PNG downloads for print shops.
+
{/* Right: Controls */}
@@ -1612,43 +1892,7 @@ export default function RiderCardPage() {
- )}
-
- {/* ── Badges tab ────────────────────────────────────────────────── */}
- {activeTab === 'badges' && (
-
-
-
-
Your Witnessed Badges
-
- Badges signed by people who saw you skate. Cryptographically unforgeable.
-
-
-
-
-
-
-
-
-
🏅 How badges work
-
- - Anyone can award you a badge when they witness your tricks
- - Badges are signed Nostr events — they can't be faked
- - The more trusted the witness (NIP-05 verified, known in the community), the more weight the badge carries
- - Gold ⭐ badges on your card show skills others have witnessed — different from your self-claimed skills
- - Ask friends and filmers to award you badges at spots you've skated
-
-
-
- )}
-
+
{/* Award badge dialog */}