feat(card): hero full-bleed redesign (no vertical rules)

The card had a portrait panel + dashed stub divider — two vertical lines with a
dead middle. Rebuilt as one composition: full-bleed photo/cover, a scrim, the
name + identity overlaid bottom-left, and the QR floating bottom-right. No photo
falls back to a themed gradient with a monogram watermark. Applied to all four
front types; the back is a plain list. Also: DEFAULT_RIDER_CARD.style is now ''
so a profile-less card doesn't claim 'Street'. Verified by rendering a real
profile, an app-profile, and an empty npub.
This commit is contained in:
SkateSpots 2026-10-02 12:25:36 +07:00
parent 0dd7e9bfa9
commit 5ccc191b32
3 changed files with 217 additions and 234 deletions

View file

@ -111,6 +111,13 @@ session with **no other memory of this project**. So:
`valid-thru`** (`passExpiry()` → EXPIRED badge on card + verify; the input is a `valid-thru`** (`passExpiry()` → EXPIRED badge on card + verify; the input is a
date picker). Card schema → `v:3`. Public `/#/card/:npub` also flips + shows date picker). Card schema → `v:3`. Public `/#/card/:npub` also flips + shows
the custom accent/logo. the custom accent/logo.
**Hero redesign (2026-10-02):** the card is now a **full-bleed photo/cover**
with a bottom scrim, the name + identity overlaid bottom-left and the **QR
floating bottom-right** — **no vertical rules / columns** (the old portrait
panel + dashed stub read as two ugly lines). No photo → a **monogram
watermark**. Card pages resolve the profile via **`useCardProfile`** (tolerant
JSON over `querySkateContent`, so it can't drop a real profile the way
`useAuthor` could); `DEFAULT_RIDER_CARD.style` is now `''` (no fake "Street").
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing - **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page the shared **`ListingTradeActions`** (used by BOTH the card and the detail page

View file

@ -43,7 +43,7 @@ export interface RiderCardConfig {
export const DEFAULT_RIDER_CARD: RiderCardConfig = { export const DEFAULT_RIDER_CARD: RiderCardConfig = {
theme: 'thug', theme: 'thug',
style: 'Street', style: '',
skills: [], skills: [],
sponsors: '', sponsors: '',
ridesFor: '', ridesFor: '',

View file

@ -1,19 +1,16 @@
/** /**
* riderCardCanvas — THE single card renderer. * riderCardCanvas — THE single card renderer.
* *
* The card is drawn once, here, on a 856×540 canvas (10× a credit card at * "Hero" credential: the profile photo (or cover) is full-bleed; the identity
* 85.6×54mm). The studio preview and the public `/#/card/:npub` view render the * overlays the lower-left on a scrim, and the QR floats bottom-right. No
* resulting PNG, so what you see is exactly what prints. * vertical rules, no columns — one composition that works with or without a
* * photo, and prints identically.
* Layout: portrait panel (left) · text column (middle) · verify stub with a
* large QR (right). Three type sizes, no filler.
*/ */
import { roleMeta, formatCardDate, passExpiry } from '@/lib/riderCard'; import { roleMeta, formatCardDate, passExpiry } from '@/lib/riderCard';
export const CARD_W = 856; export const CARD_W = 856;
export const CARD_H = 540; export const CARD_H = 540;
/** Theme palettes (also used by the CSS swatches in the studio picker). */
export const THEME_PALETTE: Record<string, { export const THEME_PALETTE: Record<string, {
bg1: string; bg2: string; bg3: string; bg1: string; bg2: string; bg3: string;
accent: string; accent2: string; accent: string; accent2: string;
@ -22,12 +19,12 @@ export const THEME_PALETTE: Record<string, {
text: string; shopColor: string; sponsorColor: string; text: string; shopColor: string; sponsorColor: string;
sidebarBg: string; sidebarBg: string;
}> = { }> = {
thug: { bg1: '#0a0a0a', bg2: '#180006', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.55)', 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)' }, thug: { bg1: '#12030a', bg2: '#2a000f', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.72)', 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.55)', 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)' }, lime: { bg1: '#071400', bg2: '#0f2400', bg3: '#040c00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.72)', 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: '#e6e6e6', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.6)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#f2f2f2', shopColor: '#ff9900', sponsorColor: '#64cfff', sidebarBg: 'rgba(200,200,200,0.04)' }, chrome: { bg1: '#171717', bg2: '#2c2c2c', bg3: '#0e0e0e', accent: '#e6e6e6', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.72)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#f2f2f2', 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.65)', 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)' }, wood: { bg1: '#3a2006', bg2: '#5a3410', bg3: '#261404', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.78)', 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.6)', 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)' }, midnight: { bg1: '#040b1a', bg2: '#071535', bg3: '#020814', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#2040aa', stripe2: '#64a0ff', muted: 'rgba(200,220,255,0.75)', chipBg: 'rgba(100,160,255,0.12)', chipBorder: 'rgba(100,160,255,0.35)', text: '#eaf2ff', 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.65)', 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)' }, sunset: { bg1: '#1a0208', bg2: '#241012', bg3: '#0e0810', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,210,170,0.78)', 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)' },
}; };
export interface CanvasCardData { export interface CanvasCardData {
@ -75,12 +72,11 @@ export interface CanvasCardData {
} }
// ─── geometry ───────────────────────────────────────────────────────────────── // ─── geometry ─────────────────────────────────────────────────────────────────
const PAD = 34; const X = 44; // left text margin
const PORTRAIT_W = 244; // left portrait panel const NAME_MAX = 560; // text column width (QR sits to the right)
const STUB_W = 214; // right verify stub (held the large QR) const QR_SIZE = 150;
const STUB_X = CARD_W - STUB_W; // 642 const QR_X = CARD_W - X - QR_SIZE; // 662
const TEXT_X = PORTRAIT_W + 34; // 278 const QR_Y = 322;
const TEXT_W = STUB_X - TEXT_X - 26; // ~338
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
async function loadImage(url: string): Promise<HTMLImageElement | null> { async function loadImage(url: string): Promise<HTMLImageElement | null> {
@ -114,16 +110,22 @@ function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number):
return t.length < text.length ? t.slice(0, -1) + '…' : t; return t.length < text.length ? t.slice(0, -1) + '…' : t;
} }
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, r: number) { function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, w: number, h: number) {
const ar = img.width / img.height; const ar = img.width / img.height;
const boxAr = w / h; const boxAr = w / h;
let dw = w, dh = h, dx = x, dy = y; let dw = w, dh = h, dx = 0, dy = 0;
if (ar > boxAr) { dw = h * ar; dx = x - (dw - w) / 2; } if (ar > boxAr) { dw = h * ar; dx = -(dw - w) / 2; }
else { dh = w / ar; dy = y - (dh - h) / 2; } else { dh = w / ar; dy = -(dh - h) / 2; }
ctx.save();
roundRect(ctx, x, y, w, h, r);
ctx.clip();
ctx.drawImage(img, dx, dy, dw, dh); ctx.drawImage(img, dx, dy, dw, dh);
}
/** Draw text with a soft shadow so it stays legible over a photo. */
function shadowed(ctx: CanvasRenderingContext2D, fn: () => void, blur = 16) {
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.75)';
ctx.shadowBlur = blur;
ctx.shadowOffsetY = 3;
fn();
ctx.restore(); ctx.restore();
} }
@ -135,203 +137,191 @@ function wordmark(ctx: CanvasRenderingContext2D, text: string, x: number, y: num
try { c.letterSpacing = prev; } catch { /* ignore */ } try { c.letterSpacing = prev; } catch { /* ignore */ }
} }
// ─── shared blocks ──────────────────────────────────────────────────────────── /** The floating white QR panel (bottom-right). */
async function drawQr(ctx: CanvasRenderingContext2D, verifyUrl: string | undefined, p: typeof THEME_PALETTE[string]) {
function drawBrand(ctx: CanvasRenderingContext2D, p: typeof THEME_PALETTE[string], kind: string) { roundRect(ctx, QR_X, QR_Y, QR_SIZE, QR_SIZE, 16);
ctx.textAlign = 'left';
ctx.fillStyle = p.accent;
ctx.font = 'bold 16px system-ui, sans-serif';
wordmark(ctx, 'SKATESPOTS', TEXT_X, 66, 4);
ctx.fillStyle = p.muted;
ctx.font = 'bold 12px system-ui, sans-serif';
wordmark(ctx, kind, TEXT_X + 1, 88, 3);
}
/** The right verify stub: dashed divider + a large QR. */
async function drawStub(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.lineWidth = 2;
ctx.setLineDash([9, 11]);
ctx.beginPath();
ctx.moveTo(STUB_X, 26);
ctx.lineTo(STUB_X, CARD_H - 26);
ctx.stroke();
ctx.restore();
const size = 156;
const qx = STUB_X + (STUB_W - size) / 2;
const qy = 140;
roundRect(ctx, qx - 12, qy - 12, size + 24, size + 24, 14);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.fill(); ctx.fill();
if (data.verifyUrl) { if (verifyUrl) {
try { try {
const QRCode = (await import('qrcode')).default; const QRCode = (await import('qrcode')).default;
const dataUrl = await QRCode.toDataURL(data.verifyUrl, { width: size * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } }); const dataUrl = await QRCode.toDataURL(verifyUrl, { width: QR_SIZE * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
const img = await loadImage(dataUrl); const img = await loadImage(dataUrl);
if (img) ctx.drawImage(img, qx, qy, size, size); if (img) ctx.drawImage(img, QR_X + 12, QR_Y + 12, QR_SIZE - 24, QR_SIZE - 24);
} catch { /* skip */ } } catch { /* skip */ }
} }
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = 'bold 12px system-ui, sans-serif'; ctx.font = 'bold 11px system-ui, sans-serif';
wordmark(ctx, data.verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', STUB_X + STUB_W / 2, qy + size + 34, 2); wordmark(ctx, verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', QR_X + QR_SIZE / 2, QR_Y + QR_SIZE + 24, 2);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
} }
/** Brand lockup, top-left, with a small dark backing pill for legibility. */
function drawBrand(ctx: CanvasRenderingContext2D, p: typeof THEME_PALETTE[string], kind: string) {
ctx.font = 'bold 15px system-ui, sans-serif';
const t1 = 'SKATESPOTS';
const w1 = ctx.measureText(t1).width + 8 * (t1.length - 1);
ctx.font = 'bold 11px system-ui, sans-serif';
const w2 = ctx.measureText(kind).width + 3 * (kind.length - 1);
const w = Math.max(w1, w2) + 34;
roundRect(ctx, X - 14, 34, w, 58, 12);
ctx.fillStyle = 'rgba(0,0,0,0.42)';
ctx.fill();
ctx.fillStyle = p.accent;
ctx.font = 'bold 15px system-ui, sans-serif';
wordmark(ctx, t1, X + 3, 60, 8);
ctx.fillStyle = 'rgba(255,255,255,0.78)';
ctx.font = 'bold 11px system-ui, sans-serif';
wordmark(ctx, kind, X + 3, 82, 3);
}
// ─── front sides ────────────────────────────────────────────────────────────── // ─── front sides ──────────────────────────────────────────────────────────────
async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) { async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) {
drawBrand(ctx, p, 'RIDER CARD'); drawBrand(ctx, p, 'RIDER CARD');
// Name — the focal point (sized to leave the QR room). const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
ctx.fillStyle = p.text; const meta: string[] = [];
ctx.font = 'bold 46px system-ui, sans-serif'; if (data.showCrews) for (const c of (data.crews ?? []).slice(0, 2)) meta.push(`🏕️ ${c}`);
ctx.fillText(fitText(ctx, data.name.toUpperCase(), TEXT_W), TEXT_X, 148); if (data.showRoles) for (const r of (data.roles ?? []).slice(0, 1)) { const m = roleMeta(r); if (m) meta.push(`${m.emoji} ${m.label}`); }
for (const b of (data.receivedBadges ?? []).slice(0, 3)) meta.push(`★ ${b.skill}`);
// One identity line: style · level · local. // Name — bottom-left hero.
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · '); shadowed(ctx, () => {
let y = 188; ctx.fillStyle = p.text;
ctx.font = 'bold 64px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.name.toUpperCase(), NAME_MAX), X, 398);
});
if (identity) { if (identity) {
ctx.fillStyle = p.accent; shadowed(ctx, () => {
ctx.font = 'bold 20px system-ui, sans-serif'; ctx.fillStyle = p.accent;
ctx.fillText(fitText(ctx, identity.toUpperCase(), TEXT_W), TEXT_X, y); ctx.font = 'bold 23px system-ui, sans-serif';
y += 30; ctx.fillText(fitText(ctx, identity, NAME_MAX), X, 440);
}, 12);
}
if (meta.length > 0) {
shadowed(ctx, () => {
ctx.fillStyle = 'rgba(255,255,255,0.82)';
ctx.font = 'bold 17px system-ui, sans-serif';
ctx.fillText(fitText(ctx, meta.join(' '), NAME_MAX), X, 476);
}, 10);
} }
if (data.nip05) { if (data.nip05) {
const [local, domain] = data.nip05.split('@'); const [local, domain] = data.nip05.split('@');
const handle = local && local !== '_' ? `@${local}` : domain ? domain : ''; const handle = local && local !== '_' ? `@${local}` : (domain ?? '');
if (handle) { if (handle) {
ctx.fillStyle = p.muted; shadowed(ctx, () => {
ctx.font = 'italic 17px Georgia, serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText(fitText(ctx, handle, TEXT_W), TEXT_X, y); ctx.font = 'italic 17px Georgia, serif';
ctx.fillText(fitText(ctx, handle, NAME_MAX), X, 508);
}, 10);
} }
} }
// Crew monogram circles on the lower band. // Crest logo, top-right (mirrors the brand).
const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : []; if (logo) {
const circleR = 15; ctx.save();
let cx = TEXT_X + circleR; roundRect(ctx, CARD_W - X - 56, 34, 56, 56, 12);
const cy = CARD_H - 66; ctx.fillStyle = 'rgba(0,0,0,0.42)';
crewNames.forEach((name) => {
ctx.beginPath();
ctx.arc(cx, cy, circleR, 0, Math.PI * 2);
ctx.fillStyle = p.chipBg;
ctx.fill(); ctx.fill();
ctx.strokeStyle = p.chipBorder; ctx.clip();
ctx.lineWidth = 1; ctx.drawImage(logo, CARD_W - X - 56, 34, 56, 56);
ctx.stroke(); ctx.restore();
ctx.fillStyle = p.accent;
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText((name[0] ?? '?').toUpperCase(), cx, cy + 5);
ctx.textAlign = 'left';
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
const nm = fitText(ctx, name, 82);
ctx.fillText(nm, cx + circleR + 8, cy + 5);
cx += circleR * 2 + 14 + ctx.measureText(nm).width + 12;
});
// Roles + witnessed badges on their own line.
const chips: string[] = [];
if (data.showRoles) for (const r of (data.roles ?? []).slice(0, 2)) { const m = roleMeta(r); if (m) chips.push(`${m.emoji} ${m.label}`); }
for (const b of (data.receivedBadges ?? []).slice(0, 3)) chips.push(`★ ${b.skill}`);
if (chips.length > 0) {
ctx.fillStyle = p.accent2;
ctx.font = 'bold 16px system-ui, sans-serif';
ctx.fillText(fitText(ctx, chips.join(' '), TEXT_W), TEXT_X, 276);
} }
if (logo) drawCover(ctx, logo, 22, 22, 58, 58, 12); await drawQr(ctx, data.verifyUrl, p);
await drawStub(ctx, data, p);
} }
async function drawParkFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { async function drawParkFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const expired = passExpiry(data.validThru) === 'expired';
drawBrand(ctx, p, 'ACCESS PASS'); drawBrand(ctx, p, 'ACCESS PASS');
const expired = passExpiry(data.validThru) === 'expired';
ctx.fillStyle = p.text; shadowed(ctx, () => {
ctx.font = 'bold 40px system-ui, sans-serif'; ctx.fillStyle = p.text;
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), TEXT_W), TEXT_X, 150); ctx.font = 'bold 58px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), NAME_MAX), X, 392);
ctx.fillStyle = expired ? '#ff5555' : p.accent; });
ctx.font = 'bold 22px system-ui, sans-serif'; shadowed(ctx, () => {
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), TEXT_W), TEXT_X, 190); ctx.fillStyle = expired ? '#ff6b6b' : p.accent;
ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillStyle = p.muted; ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), NAME_MAX), X, 432);
ctx.font = '15px system-ui, sans-serif'; }, 12);
ctx.fillText('ADMITS', TEXT_X, 240); shadowed(ctx, () => {
ctx.fillStyle = p.text; ctx.fillStyle = 'rgba(255,255,255,0.82)';
ctx.font = 'bold 30px system-ui, sans-serif'; ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.recipientName || data.name, TEXT_W), TEXT_X, 278); ctx.fillText(fitText(ctx, `ADMITS ${data.recipientName || data.name}`, NAME_MAX), X, 470);
}, 10);
if (data.validThru) { if (data.validThru) {
ctx.fillStyle = p.muted; shadowed(ctx, () => {
ctx.font = '14px system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText(`VALID THRU ${data.validThru}`, TEXT_X, CARD_H - 62); ctx.font = '17px system-ui, sans-serif';
ctx.fillText(`VALID THRU ${data.validThru}`, X, 504);
}, 10);
} }
await drawStub(ctx, data, p); await drawQr(ctx, data.verifyUrl, p);
} }
async function drawCrewFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { async function drawCrewFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const c = data.crew; const c = data.crew;
drawBrand(ctx, p, 'CREW CARD'); drawBrand(ctx, p, 'CREW CARD');
ctx.fillStyle = p.text; shadowed(ctx, () => {
ctx.font = 'bold 42px system-ui, sans-serif'; ctx.fillStyle = p.text;
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), TEXT_W), TEXT_X, 154); ctx.font = 'bold 62px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), NAME_MAX), X, 396);
});
const line2 = [c?.location, c?.founded ? `EST. ${c.founded}` : ''].filter(Boolean).join(' · '); const line2 = [c?.location, c?.founded ? `EST. ${c.founded}` : ''].filter(Boolean).join(' · ');
if (line2) { if (line2) {
ctx.fillStyle = p.accent; shadowed(ctx, () => {
ctx.font = 'bold 19px system-ui, sans-serif'; ctx.fillStyle = p.accent;
ctx.fillText(fitText(ctx, line2.toUpperCase(), TEXT_W), TEXT_X, 194); ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText(fitText(ctx, line2.toUpperCase(), NAME_MAX), X, 436);
}, 12);
} }
if (c) { if (c) {
ctx.fillStyle = p.muted; shadowed(ctx, () => {
ctx.font = '17px system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.82)';
ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, TEXT_X, 244);
if (c.lookingFor.length > 0) {
ctx.fillStyle = p.accent2;
ctx.font = 'bold 18px system-ui, sans-serif'; ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText('OPEN CALL', TEXT_X, 284); ctx.fillText(`${c.members} MEMBERS ${c.spots} SPOTS${c.lookingFor.length ? ' OPEN CALL' : ''}`, X, 474);
} }, 10);
} }
await drawStub(ctx, data, p); await drawQr(ctx, data.verifyUrl, p);
} }
async function drawEventFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { async function drawEventFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const e = data.event; const e = data.event;
drawBrand(ctx, p, 'EVENT PASS'); drawBrand(ctx, p, 'EVENT PASS');
ctx.fillStyle = p.text; shadowed(ctx, () => {
ctx.font = 'bold 38px system-ui, sans-serif'; ctx.fillStyle = p.text;
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), TEXT_W), TEXT_X, 150); ctx.font = 'bold 54px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), NAME_MAX), X, 388);
});
if (e) { if (e) {
ctx.fillStyle = p.accent; shadowed(ctx, () => {
ctx.font = 'bold 19px system-ui, sans-serif'; ctx.fillStyle = p.accent;
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), TEXT_W), TEXT_X, 190); ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), NAME_MAX), X, 428);
}, 12);
if (e.location) { if (e.location) {
ctx.fillStyle = p.muted; shadowed(ctx, () => {
ctx.font = '16px system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.78)';
ctx.fillText(fitText(ctx, `📍 ${e.location}`, TEXT_W), TEXT_X, 222); ctx.font = '17px system-ui, sans-serif';
ctx.fillText(fitText(ctx, `📍 ${e.location}`, NAME_MAX), X, 462);
}, 10);
} }
} }
shadowed(ctx, () => {
ctx.fillStyle = p.muted; ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = '15px system-ui, sans-serif'; ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText('ADMITS', TEXT_X, 274); ctx.fillText(fitText(ctx, `ADMITS ${e?.holder ?? data.name}`, NAME_MAX), X, 500);
ctx.fillStyle = p.text; }, 10);
ctx.font = 'bold 28px system-ui, sans-serif'; await drawQr(ctx, data.verifyUrl, p);
ctx.fillText(fitText(ctx, e?.holder ?? data.name, TEXT_W), TEXT_X, 310);
await drawStub(ctx, data, p);
} }
// ─── back ───────────────────────────────────────────────────────────────────── // ─── back ─────────────────────────────────────────────────────────────────────
@ -347,51 +337,30 @@ async function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p:
if (data.validThru) lines.push(['VALID THRU', data.validThru]); if (data.validThru) lines.push(['VALID THRU', data.validThru]);
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
ctx.font = 'bold 16px system-ui, sans-serif'; ctx.font = 'bold 15px system-ui, sans-serif';
wordmark(ctx, 'SKATESPOTS', PAD, 62, 4); wordmark(ctx, 'SKATESPOTS', X, 64, 4);
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = 'bold 12px system-ui, sans-serif'; ctx.font = 'bold 11px system-ui, sans-serif';
wordmark(ctx, 'CARD BACK', PAD + 1, 84, 3); wordmark(ctx, 'CARD BACK', X + 1, 84, 3);
let y = 160; let y = 168;
const width = QR_X - X - 28;
for (const [label, value] of lines.slice(0, 6)) { for (const [label, value] of lines.slice(0, 6)) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = 'bold 13px system-ui, sans-serif'; ctx.font = 'bold 13px system-ui, sans-serif';
wordmark(ctx, label, PAD, y, 3); wordmark(ctx, label, X, y, 3);
ctx.fillStyle = p.text; ctx.fillStyle = p.text;
ctx.font = 'bold 24px system-ui, sans-serif'; ctx.font = 'bold 26px system-ui, sans-serif';
ctx.fillText(fitText(ctx, value, STUB_X - PAD - 24), PAD, y + 36); ctx.fillText(fitText(ctx, value, width), X, y + 38);
y += 74; y += 78;
} }
if (lines.length === 0) { if (lines.length === 0) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = '20px system-ui, sans-serif'; ctx.font = '20px system-ui, sans-serif';
ctx.fillText('Add crews, roles or sponsors to fill the back.', PAD, y); ctx.fillText('Add crews, roles or sponsors to fill the back.', X, y);
} }
// right stub: the website + a small QR await drawQr(ctx, data.verifyUrl, p);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.lineWidth = 2;
ctx.setLineDash([9, 11]);
ctx.beginPath(); ctx.moveTo(STUB_X, 26); ctx.lineTo(STUB_X, CARD_H - 26); ctx.stroke();
ctx.restore();
const size = 120;
const qx = STUB_X + (STUB_W - size) / 2;
if (data.verifyUrl) {
try {
const QRCode = (await import('qrcode')).default;
const dataUrl = await QRCode.toDataURL(data.verifyUrl, { width: size * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#ffffff', light: '#00000000' } });
const img = await loadImage(dataUrl);
if (img) ctx.drawImage(img, qx, 150, size, size);
} catch { /* skip */ }
}
ctx.textAlign = 'center';
ctx.fillStyle = p.muted;
ctx.font = 'bold 12px system-ui, sans-serif';
wordmark(ctx, 'skate-spots.org', STUB_X + STUB_W / 2, 300, 2);
ctx.textAlign = 'left';
} }
// ─── entry point ────────────────────────────────────────────────────────────── // ─── entry point ──────────────────────────────────────────────────────────────
@ -417,46 +386,48 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
return canvas.toDataURL('image/png'); return canvas.toDataURL('image/png');
} }
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H); // Full-bleed background: the photo/cover, else a themed gradient.
bg.addColorStop(0, p.bg1); const bgUrl = data.type === 'crew' ? data.crew?.image : data.type === 'event' ? data.event?.image : data.avatarUrl;
bg.addColorStop(0.55, p.bg2); const bgImg = bgUrl ? await loadImage(bgUrl) : null;
bg.addColorStop(1, p.bg3);
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
ctx.fillStyle = bg;
ctx.fill();
const portraitUrl = data.type === 'crew' ? data.crew?.image : data.type === 'event' ? data.event?.image : data.avatarUrl;
const portrait = portraitUrl ? await loadImage(portraitUrl) : null;
ctx.save(); ctx.save();
roundRect(ctx, 0, 0, PORTRAIT_W, CARD_H, 28); roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
ctx.clip(); ctx.clip();
if (portrait) {
drawCover(ctx, portrait, 0, 0, PORTRAIT_W, CARD_H, 28);
} else {
ctx.fillStyle = p.sidebarBg;
ctx.fillRect(0, 0, PORTRAIT_W, CARD_H);
// Initials monogram — more personal than a generic skateboard glyph.
const initials = (data.name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] ?? '').join('').toUpperCase() || '?';
ctx.fillStyle = p.accent;
ctx.font = 'bold 96px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.globalAlpha = 0.85;
ctx.fillText(initials, PORTRAIT_W / 2, CARD_H / 2 + 34);
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
}
const shade = ctx.createLinearGradient(0, CARD_H - 180, 0, CARD_H);
shade.addColorStop(0, 'rgba(0,0,0,0)');
shade.addColorStop(1, 'rgba(0,0,0,0.72)');
ctx.fillStyle = shade;
ctx.fillRect(0, CARD_H - 180, PORTRAIT_W, 180);
ctx.restore();
ctx.strokeStyle = p.accent; if (bgImg) {
ctx.globalAlpha = 0.25; drawCover(ctx, bgImg, CARD_W, CARD_H);
ctx.lineWidth = 2; } else {
ctx.beginPath(); ctx.moveTo(PORTRAIT_W, 0); ctx.lineTo(PORTRAIT_W, CARD_H); ctx.stroke(); const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
ctx.globalAlpha = 1; bg.addColorStop(0, p.bg1);
bg.addColorStop(0.55, p.bg2);
bg.addColorStop(1, p.bg3);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CARD_W, CARD_H);
// Monogram watermark so a photo-less card still feels personal.
const initials = (data.name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] ?? '').join('').toUpperCase() || '?';
ctx.globalAlpha = 0.10;
ctx.fillStyle = p.accent;
ctx.font = 'bold 300px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(initials, CARD_W - 40, CARD_H - 40);
ctx.textAlign = 'left';
ctx.globalAlpha = 1;
}
// Top + bottom scrims for legibility.
const top = ctx.createLinearGradient(0, 0, 0, 170);
top.addColorStop(0, 'rgba(0,0,0,0.55)');
top.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = top;
ctx.fillRect(0, 0, CARD_W, 170);
const bot = ctx.createLinearGradient(0, 210, 0, CARD_H);
bot.addColorStop(0, 'rgba(0,0,0,0)');
bot.addColorStop(0.55, 'rgba(0,0,0,0.45)');
bot.addColorStop(1, 'rgba(0,0,0,0.86)');
ctx.fillStyle = bot;
ctx.fillRect(0, 210, CARD_W, CARD_H - 210);
ctx.restore();
const logo = data.type === 'rider' && data.logoUrl ? await loadImage(data.logoUrl) : null; const logo = data.type === 'rider' && data.logoUrl ? await loadImage(data.logoUrl) : null;
@ -465,11 +436,16 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
else if (data.type === 'crew') await drawCrewFront(ctx, data, p); else if (data.type === 'crew') await drawCrewFront(ctx, data, p);
else await drawEventFront(ctx, data, p); else await drawEventFront(ctx, data, p);
// Signature accent bar (bottom).
const bar = ctx.createLinearGradient(0, 0, CARD_W, 0); const bar = ctx.createLinearGradient(0, 0, CARD_W, 0);
bar.addColorStop(0, p.stripe1); bar.addColorStop(0, p.stripe1);
bar.addColorStop(1, p.stripe2); bar.addColorStop(1, p.stripe2);
ctx.save();
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
ctx.clip();
ctx.fillStyle = bar; ctx.fillStyle = bar;
ctx.fillRect(0, CARD_H - 10, CARD_W, 10); ctx.fillRect(0, CARD_H - 10, CARD_W, 10);
ctx.restore();
if (data.holographic) { if (data.holographic) {
ctx.save(); ctx.save();
@ -478,7 +454,7 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
const sheen = ctx.createLinearGradient(0, CARD_H, CARD_W, 0); const sheen = ctx.createLinearGradient(0, CARD_H, CARD_W, 0);
sheen.addColorStop(0, 'rgba(255,255,255,0)'); sheen.addColorStop(0, 'rgba(255,255,255,0)');
sheen.addColorStop(0.42, 'rgba(255,255,255,0)'); sheen.addColorStop(0.42, 'rgba(255,255,255,0)');
sheen.addColorStop(0.5, 'rgba(255,255,255,0.10)'); sheen.addColorStop(0.5, 'rgba(255,255,255,0.09)');
sheen.addColorStop(0.58, 'rgba(255,255,255,0)'); sheen.addColorStop(0.58, 'rgba(255,255,255,0)');
sheen.addColorStop(1, 'rgba(255,255,255,0)'); sheen.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = sheen; ctx.fillStyle = sheen;