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:
parent
0dd7e9bfa9
commit
5ccc191b32
3 changed files with 217 additions and 234 deletions
|
|
@ -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
|
||||
date picker). Card schema → `v:3`. Public `/#/card/:npub` also flips + shows
|
||||
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
|
||||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||||
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export interface RiderCardConfig {
|
|||
|
||||
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
|
||||
theme: 'thug',
|
||||
style: 'Street',
|
||||
style: '',
|
||||
skills: [],
|
||||
sponsors: '',
|
||||
ridesFor: '',
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
/**
|
||||
* riderCardCanvas — THE single card renderer.
|
||||
*
|
||||
* The card is drawn once, here, on a 856×540 canvas (10× a credit card at
|
||||
* 85.6×54mm). The studio preview and the public `/#/card/:npub` view render the
|
||||
* resulting PNG, so what you see is exactly what prints.
|
||||
*
|
||||
* Layout: portrait panel (left) · text column (middle) · verify stub with a
|
||||
* large QR (right). Three type sizes, no filler.
|
||||
* "Hero" credential: the profile photo (or cover) is full-bleed; the identity
|
||||
* overlays the lower-left on a scrim, and the QR floats bottom-right. No
|
||||
* vertical rules, no columns — one composition that works with or without a
|
||||
* photo, and prints identically.
|
||||
*/
|
||||
import { roleMeta, formatCardDate, passExpiry } from '@/lib/riderCard';
|
||||
|
||||
export const CARD_W = 856;
|
||||
export const CARD_H = 540;
|
||||
|
||||
/** Theme palettes (also used by the CSS swatches in the studio picker). */
|
||||
export const THEME_PALETTE: Record<string, {
|
||||
bg1: string; bg2: string; bg3: string;
|
||||
accent: string; accent2: string;
|
||||
|
|
@ -22,12 +19,12 @@ export const THEME_PALETTE: Record<string, {
|
|||
text: string; shopColor: string; sponsorColor: string;
|
||||
sidebarBg: string;
|
||||
}> = {
|
||||
thug: { bg1: '#0a0a0a', bg2: '#180006', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.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)' },
|
||||
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)' },
|
||||
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)' },
|
||||
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)' },
|
||||
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)' },
|
||||
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)' },
|
||||
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: '#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: '#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: '#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: '#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: '#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 {
|
||||
|
|
@ -75,12 +72,11 @@ export interface CanvasCardData {
|
|||
}
|
||||
|
||||
// ─── geometry ─────────────────────────────────────────────────────────────────
|
||||
const PAD = 34;
|
||||
const PORTRAIT_W = 244; // left portrait panel
|
||||
const STUB_W = 214; // right verify stub (held the large QR)
|
||||
const STUB_X = CARD_W - STUB_W; // 642
|
||||
const TEXT_X = PORTRAIT_W + 34; // 278
|
||||
const TEXT_W = STUB_X - TEXT_X - 26; // ~338
|
||||
const X = 44; // left text margin
|
||||
const NAME_MAX = 560; // text column width (QR sits to the right)
|
||||
const QR_SIZE = 150;
|
||||
const QR_X = CARD_W - X - QR_SIZE; // 662
|
||||
const QR_Y = 322;
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
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;
|
||||
}
|
||||
|
||||
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 boxAr = w / h;
|
||||
let dw = w, dh = h, dx = x, dy = y;
|
||||
if (ar > boxAr) { dw = h * ar; dx = x - (dw - w) / 2; }
|
||||
else { dh = w / ar; dy = y - (dh - h) / 2; }
|
||||
ctx.save();
|
||||
roundRect(ctx, x, y, w, h, r);
|
||||
ctx.clip();
|
||||
let dw = w, dh = h, dx = 0, dy = 0;
|
||||
if (ar > boxAr) { dw = h * ar; dx = -(dw - w) / 2; }
|
||||
else { dh = w / ar; dy = -(dh - h) / 2; }
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -135,203 +137,191 @@ function wordmark(ctx: CanvasRenderingContext2D, text: string, x: number, y: num
|
|||
try { c.letterSpacing = prev; } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── shared blocks ────────────────────────────────────────────────────────────
|
||||
|
||||
function drawBrand(ctx: CanvasRenderingContext2D, p: typeof THEME_PALETTE[string], kind: string) {
|
||||
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);
|
||||
/** The floating white QR panel (bottom-right). */
|
||||
async function drawQr(ctx: CanvasRenderingContext2D, verifyUrl: string | undefined, p: typeof THEME_PALETTE[string]) {
|
||||
roundRect(ctx, QR_X, QR_Y, QR_SIZE, QR_SIZE, 16);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
if (data.verifyUrl) {
|
||||
if (verifyUrl) {
|
||||
try {
|
||||
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);
|
||||
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 */ }
|
||||
}
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = 'bold 12px system-ui, sans-serif';
|
||||
wordmark(ctx, data.verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', STUB_X + STUB_W / 2, qy + size + 34, 2);
|
||||
ctx.font = 'bold 11px system-ui, sans-serif';
|
||||
wordmark(ctx, verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', QR_X + QR_SIZE / 2, QR_Y + QR_SIZE + 24, 2);
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) {
|
||||
drawBrand(ctx, p, 'RIDER CARD');
|
||||
|
||||
// Name — the focal point (sized to leave the QR room).
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 46px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, data.name.toUpperCase(), TEXT_W), TEXT_X, 148);
|
||||
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
|
||||
const meta: string[] = [];
|
||||
if (data.showCrews) for (const c of (data.crews ?? []).slice(0, 2)) meta.push(`🏕️ ${c}`);
|
||||
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.
|
||||
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
|
||||
let y = 188;
|
||||
// Name — bottom-left hero.
|
||||
shadowed(ctx, () => {
|
||||
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) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 20px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, identity.toUpperCase(), TEXT_W), TEXT_X, y);
|
||||
y += 30;
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 23px system-ui, sans-serif';
|
||||
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) {
|
||||
const [local, domain] = data.nip05.split('@');
|
||||
const handle = local && local !== '_' ? `@${local}` : domain ? domain : '';
|
||||
const handle = local && local !== '_' ? `@${local}` : (domain ?? '');
|
||||
if (handle) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = 'italic 17px Georgia, serif';
|
||||
ctx.fillText(fitText(ctx, handle, TEXT_W), TEXT_X, y);
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
ctx.font = 'italic 17px Georgia, serif';
|
||||
ctx.fillText(fitText(ctx, handle, NAME_MAX), X, 508);
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Crew monogram circles on the lower band.
|
||||
const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : [];
|
||||
const circleR = 15;
|
||||
let cx = TEXT_X + circleR;
|
||||
const cy = CARD_H - 66;
|
||||
crewNames.forEach((name) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, circleR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.chipBg;
|
||||
// Crest logo, top-right (mirrors the brand).
|
||||
if (logo) {
|
||||
ctx.save();
|
||||
roundRect(ctx, CARD_W - X - 56, 34, 56, 56, 12);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.42)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = p.chipBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
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);
|
||||
ctx.clip();
|
||||
ctx.drawImage(logo, CARD_W - X - 56, 34, 56, 56);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (logo) drawCover(ctx, logo, 22, 22, 58, 58, 12);
|
||||
await drawStub(ctx, data, p);
|
||||
await drawQr(ctx, data.verifyUrl, p);
|
||||
}
|
||||
|
||||
async function drawParkFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
|
||||
const expired = passExpiry(data.validThru) === 'expired';
|
||||
drawBrand(ctx, p, 'ACCESS PASS');
|
||||
const expired = passExpiry(data.validThru) === 'expired';
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 40px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), TEXT_W), TEXT_X, 150);
|
||||
|
||||
ctx.fillStyle = expired ? '#ff5555' : p.accent;
|
||||
ctx.font = 'bold 22px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), TEXT_W), TEXT_X, 190);
|
||||
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', TEXT_X, 240);
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 30px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, data.recipientName || data.name, TEXT_W), TEXT_X, 278);
|
||||
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 58px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), NAME_MAX), X, 392);
|
||||
});
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = expired ? '#ff6b6b' : p.accent;
|
||||
ctx.font = 'bold 24px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), NAME_MAX), X, 432);
|
||||
}, 12);
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.82)';
|
||||
ctx.font = 'bold 19px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, `ADMITS ${data.recipientName || data.name}`, NAME_MAX), X, 470);
|
||||
}, 10);
|
||||
if (data.validThru) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '14px system-ui, sans-serif';
|
||||
ctx.fillText(`VALID THRU ${data.validThru}`, TEXT_X, CARD_H - 62);
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
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]) {
|
||||
const c = data.crew;
|
||||
drawBrand(ctx, p, 'CREW CARD');
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 42px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), TEXT_W), TEXT_X, 154);
|
||||
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = p.text;
|
||||
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(' · ');
|
||||
if (line2) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 19px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, line2.toUpperCase(), TEXT_W), TEXT_X, 194);
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 22px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, line2.toUpperCase(), NAME_MAX), X, 436);
|
||||
}, 12);
|
||||
}
|
||||
|
||||
if (c) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '17px system-ui, sans-serif';
|
||||
ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, TEXT_X, 244);
|
||||
if (c.lookingFor.length > 0) {
|
||||
ctx.fillStyle = p.accent2;
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.82)';
|
||||
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]) {
|
||||
const e = data.event;
|
||||
drawBrand(ctx, p, 'EVENT PASS');
|
||||
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 38px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), TEXT_W), TEXT_X, 150);
|
||||
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 54px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), NAME_MAX), X, 388);
|
||||
});
|
||||
if (e) {
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 19px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), TEXT_W), TEXT_X, 190);
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = p.accent;
|
||||
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) {
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '16px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, `📍 ${e.location}`, TEXT_W), TEXT_X, 222);
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.78)';
|
||||
ctx.font = '17px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, `📍 ${e.location}`, NAME_MAX), X, 462);
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = '15px system-ui, sans-serif';
|
||||
ctx.fillText('ADMITS', TEXT_X, 274);
|
||||
ctx.fillStyle = p.text;
|
||||
ctx.font = 'bold 28px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, e?.holder ?? data.name, TEXT_W), TEXT_X, 310);
|
||||
await drawStub(ctx, data, p);
|
||||
shadowed(ctx, () => {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.font = 'bold 19px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, `ADMITS ${e?.holder ?? data.name}`, NAME_MAX), X, 500);
|
||||
}, 10);
|
||||
await drawQr(ctx, data.verifyUrl, p);
|
||||
}
|
||||
|
||||
// ─── back ─────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -347,51 +337,30 @@ async function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p:
|
|||
if (data.validThru) lines.push(['VALID THRU', data.validThru]);
|
||||
|
||||
ctx.fillStyle = p.accent;
|
||||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||||
wordmark(ctx, 'SKATESPOTS', PAD, 62, 4);
|
||||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||
wordmark(ctx, 'SKATESPOTS', X, 64, 4);
|
||||
ctx.fillStyle = p.muted;
|
||||
ctx.font = 'bold 12px system-ui, sans-serif';
|
||||
wordmark(ctx, 'CARD BACK', PAD + 1, 84, 3);
|
||||
ctx.font = 'bold 11px system-ui, sans-serif';
|
||||
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)) {
|
||||
ctx.fillStyle = p.muted;
|
||||
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.font = 'bold 24px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, value, STUB_X - PAD - 24), PAD, y + 36);
|
||||
y += 74;
|
||||
ctx.font = 'bold 26px system-ui, sans-serif';
|
||||
ctx.fillText(fitText(ctx, value, width), X, y + 38);
|
||||
y += 78;
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
ctx.fillStyle = p.muted;
|
||||
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
|
||||
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';
|
||||
await drawQr(ctx, data.verifyUrl, p);
|
||||
}
|
||||
|
||||
// ─── entry point ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -417,46 +386,48 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
|
|||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
|
||||
bg.addColorStop(0, p.bg1);
|
||||
bg.addColorStop(0.55, p.bg2);
|
||||
bg.addColorStop(1, p.bg3);
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fill();
|
||||
// Full-bleed background: the photo/cover, else a themed gradient.
|
||||
const bgUrl = data.type === 'crew' ? data.crew?.image : data.type === 'event' ? data.event?.image : data.avatarUrl;
|
||||
const bgImg = bgUrl ? await loadImage(bgUrl) : null;
|
||||
|
||||
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();
|
||||
roundRect(ctx, 0, 0, PORTRAIT_W, CARD_H, 28);
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
|
||||
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;
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(PORTRAIT_W, 0); ctx.lineTo(PORTRAIT_W, CARD_H); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
if (bgImg) {
|
||||
drawCover(ctx, bgImg, CARD_W, CARD_H);
|
||||
} else {
|
||||
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
|
||||
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;
|
||||
|
||||
|
|
@ -465,11 +436,16 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
|
|||
else if (data.type === 'crew') await drawCrewFront(ctx, data, p);
|
||||
else await drawEventFront(ctx, data, p);
|
||||
|
||||
// Signature accent bar (bottom).
|
||||
const bar = ctx.createLinearGradient(0, 0, CARD_W, 0);
|
||||
bar.addColorStop(0, p.stripe1);
|
||||
bar.addColorStop(1, p.stripe2);
|
||||
ctx.save();
|
||||
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = bar;
|
||||
ctx.fillRect(0, CARD_H - 10, CARD_W, 10);
|
||||
ctx.restore();
|
||||
|
||||
if (data.holographic) {
|
||||
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);
|
||||
sheen.addColorStop(0, '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(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = sheen;
|
||||
|
|
|
|||
Loading…
Reference in a new issue