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:
parent
79dc1e1dcf
commit
8d273428f3
1 changed files with 397 additions and 153 deletions
|
|
@ -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> {
|
||||
|
|
@ -158,46 +159,347 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
|||
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) ──────────────────────────
|
||||
// ─── 4. QR code (top-right, generated first — data: URI safe) ────────────
|
||||
const qrUrl = `https://skate-spots.org/${data.npub}`;
|
||||
const qrSize = 180;
|
||||
const qrX = CARD_W - PAD - qrSize;
|
||||
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';
|
||||
|
|
@ -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">
|
||||
|
||||
{/* 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 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Right: Controls */}
|
||||
|
|
@ -1612,42 +1892,6 @@ 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>
|
||||
|
||||
{/* Award badge dialog */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue