diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx index 38fe057..1532023 100644 --- a/src/components/RiderCard.tsx +++ b/src/components/RiderCard.tsx @@ -1,11 +1,14 @@ /** - * RiderCard — the visual card used by both the `/card` editor and the public - * `/card/:npub` view. Extracted so there is ONE React rendering of the card - * (the canvas export in `RiderCardPage` remains the print renderer). + * RiderCard — the card's data shape + the ONE preview component. + * + * The card itself is drawn by `@/lib/riderCardCanvas` (a single canvas + * renderer), so the studio preview and the public `/#/card/:npub` view are + * pixel-identical to what prints. There is intentionally NO second React + * implementation of the card. */ import { useEffect, useState } from 'react'; -import { SKILL_OPTIONS, shortNpub, roleMeta, formatCardDate, passExpiry, type ThemeId } from '@/lib/riderCard'; -import { useTranslation } from '@/contexts/language'; +import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas'; +import type { ThemeId } from '@/lib/riderCard'; import { cn } from '@/lib/utils'; export interface BadgeAward { @@ -20,14 +23,12 @@ export interface CardData { type: 'rider' | 'park' | 'crew' | 'event'; theme: ThemeId; holographic: boolean; - // Rider name: string; nip05?: string; avatar?: string; pubkey: string; npub: string; style: string; - /** Derived level from the Skater profile (kind 35000). */ level?: string; skills: string[]; sponsors: string; @@ -40,554 +41,92 @@ export interface CardData { eventsCreated: number; memberYear: string; receivedBadges: BadgeAward[]; - /** When set, the printed QR opens the verify page for this card. */ verifyUrl?: string; - /** Custom accent hex overriding the theme's primary accent. */ accent?: string; - /** Optional second accent (stripe gradient end). */ - accent2?: string; - /** Optional personal logo / crest URL. */ logo?: string; - /** Which side to render (default front). */ side?: 'front' | 'back'; - // Identity facets (derived live, toggled on the card) crews?: { name: string; image?: string }[]; local?: string; roles?: string[]; showCrews?: boolean; showLocal?: boolean; showRoles?: boolean; - // Crew card (derived from the crew's own signed kind 35006 event) - crew?: { - name: string; - image?: string; - pubkey: string; - identifier: string; - location?: string; - founded?: string; - members: number; - focus?: string; - vibes: string[]; - lookingFor: string[]; - spots: number; - }; - // Park + crew?: CanvasCardData['crew']; + event?: CanvasCardData['event']; issuerName: string; recipientName: string; accessLevel: string; validThru: string; - // Event pass / wristband (derived from the event's own signed kind 31923) - event?: { - title: string; - start: number; - end?: number; - location?: string; - image?: string; - pubkey: string; - identifier: string; - going?: number; - holder?: string; +} + +function toCanvasData(d: CardData): CanvasCardData { + return { + type: d.type, + themeId: d.theme, + accentOverride: d.accent, + name: d.name, + nip05: d.nip05, + avatarUrl: d.avatar, + logoUrl: d.logo, + pubkey: d.pubkey, + npub: d.npub, + style: d.style, + level: d.level, + sponsors: d.sponsors, + ridesFor: d.ridesFor, + showSpots: d.showSpots, + showZaps: d.showZaps, + showEvents: d.showEvents, + showCrews: d.showCrews ?? true, + showLocal: d.showLocal ?? true, + showRoles: d.showRoles ?? true, + spotsAdded: d.spotsAdded, + zapsReceived: d.zapsReceived, + eventsCreated: d.eventsCreated, + memberYear: d.memberYear, + receivedBadges: d.receivedBadges, + verifyUrl: d.verifyUrl, + local: d.local, + crews: (d.crews ?? []).map((c) => c.name), + roles: d.roles, + crew: d.crew, + event: d.event, + issuerName: d.issuerName, + recipientName: d.recipientName, + accessLevel: d.accessLevel, + validThru: d.validThru, + holographic: d.holographic, }; } -/** Real QR (lazy-imported so canvas API access stays out of module load). */ -function CardQr({ url }: { url?: string }) { +/** + * Live canvas-backed card preview. Debounced so a fast typist doesn't kick off + * a canvas + QR render on every keystroke. + */ +export function CardPreview({ d, className }: { d: CardData; className?: string }) { const [src, setSrc] = useState(null); useEffect(() => { let cancelled = false; - if (!url) { setSrc(null); return; } - (async () => { + const handle = setTimeout(async () => { try { - const QRCode = (await import('qrcode')).default; - const dataUrl = await QRCode.toDataURL(url, { - width: 88, - margin: 0, - errorCorrectionLevel: 'M', - color: { dark: '#000000', light: '#ffffff' }, - }); - if (!cancelled) setSrc(dataUrl); + const url = await drawCardToCanvas(toCanvasData(d), d.side ?? 'front'); + if (!cancelled) setSrc(url); } catch { if (!cancelled) setSrc(null); } - })(); - return () => { cancelled = true; }; - }, [url]); - - return ( -
- {src - ? QR - :
} -
- ); -} - -// ─── Card visual: Rider front ───────────────────────────────────────────────── -// Layout is STRICTLY pixel-fixed to match the 340×214px card container. -function RiderFront({ d }: { d: CardData }) { - const { t } = useTranslation(); - const witnessedBadges = d.receivedBadges.slice(0, 2); - - return ( -
- - {/* Row 1: header */} -
-
-
- 🛹 SkateSpots -
-
{t('labUi.rider.previewLicense')}
-
-
-
npub
-
- {shortNpub(d.pubkey)} -
-
{t('labUi.rider.previewSince', { year: d.memberYear })}
-
-
- - {/* Identity facets: local + crew crests */} - {((d.showLocal !== false && d.local) || (d.showCrews !== false && (d.crews?.length ?? 0) > 0)) && ( -
- {d.showLocal !== false && d.local && ( - 📍 {d.local} - )} - {d.showCrews !== false && (d.crews ?? []).slice(0, 3).map((c, i) => ( - - {c.image - ? - : } - {c.name} - - ))} -
- )} - - {/* Row 2: main body */} -
- - {/* Avatar */} -
-
- {d.avatar ? ( - {d.name} - ) : ( -
🛹
- )} -
-
- {d.showSpots && ( -
- {t('labUi.pyramid.spots')} - {d.spotsAdded} -
- )} - {d.showZaps && ( -
- ⚡ - {d.zapsReceived} -
- )} - {d.showEvents && ( -
- {t('labUi.rider.previewEvts')} - {d.eventsCreated} -
- )} -
-
- - {/* Identity block */} -
-
- {d.logo && ( - - )} - {d.name} -
- {d.nip05 && ( -
@{d.nip05.split('@')[0]}
- )} - {d.style && ( - - {d.style} - - )} - {d.level && ( - - {d.level} - - )} - {d.ridesFor && ( -
🏪 {d.ridesFor}
- )} - {d.sponsors && ( -
🤝 {d.sponsors}
- )} - {/* Roles + witnessed badges only — self-claimed skills are gone */} -
- {d.showRoles !== false && (d.roles ?? []).slice(0, 3).map(r => { - const m = roleMeta(r); - return m ? ( - - {m.emoji} {t(`cardUi.roles.${m.role}`)} - - ) : null; - })} - {witnessedBadges.map((b, i) => { - const s = SKILL_OPTIONS.find(o => o.id === b.skill); - return s ? ( - - ⭐ {s.label} - - ) : null; - })} -
-
- - {/* QR */} -
- -
- {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} -
-
-
- - {/* Bottom bar */} -
- - skate-spots.org · Powered by Nostr - - - {shortNpub(d.pubkey)} - -
-
- ); -} - -// ─── Card visual: Park Access front ────────────────────────────────────────── -function ParkFront({ d }: { d: CardData }) { - const { t } = useTranslation(); - - return ( -
- -
-
-
- 🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')} -
-
{t('labUi.rider.previewAccessPass')}
-
-
- {passExpiry(d.validThru) === 'expired' - ? {t('cardUi.expired')} - : {d.accessLevel || 'Full Access'}} -
-
- -
- -
-
- {d.avatar ? ( - {d.recipientName} - ) : ( -
🛹
- )} -
-
- -
-
{d.recipientName || t('labUi.rider.previewMemberName')}
-
{shortNpub(d.pubkey)}
-
{t('labUi.rider.previewIssuedBy')}
- {d.validThru && ( -
- {t('labUi.rider.previewValidThru')} - {d.validThru} -
- )} -
- -
- -
- {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} -
-
-
- -
- - skate-spots.org · Powered by Nostr - -
- {t('labUi.rider.previewNostrVerified')} -
-
-
- ); -} - -// ─── Card visual: Crew front ───────────────────────────────────────────────── -function CrewFront({ d }: { d: CardData }) { - const { t } = useTranslation(); - const c = d.crew; - if (!c) return null; - - return ( -
- - {/* Row 1: header */} -
-
-
- 🛹 SkateSpots -
-
{t('cardUi.crew.crewCard')}
-
-
-
npub
-
- {shortNpub(c.pubkey)} -
- {c.founded &&
{t('cardUi.crew.est', { year: c.founded })}
} -
-
- - {/* Row 2: main body */} -
- - {/* Logo + stats */} -
-
- {c.image ? ( - {c.name} - ) : ( -
🏕️
- )} -
-
-
- {t('cardUi.crew.members')} - {c.members} -
-
- {t('cardUi.crew.spots')} - {c.spots} -
-
-
- - {/* Identity block */} -
-
{c.name}
- {c.location && ( -
📍 {c.location}
- )} - {c.lookingFor.length > 0 && ( - - {t('cardUi.crew.openCall')} - - )} -
- {c.vibes.slice(0, 5).map(v => ( - - {v} - - ))} -
-
- - {/* QR */} -
- -
- {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} -
-
-
- - {/* Bottom bar */} -
- - skate-spots.org · Powered by Nostr - - {t('cardUi.crew.verifiedCrew')} -
-
- ); -} - -// ─── Card visual: Event pass / wristband ───────────────────────────────────── -function EventFront({ d }: { d: CardData }) { - const { t } = useTranslation(); - const e = d.event; - if (!e) return null; - const dateText = formatCardDate(e.start, e.end); - - return ( -
- - {/* Row 1: header */} -
-
-
- 🛹 SkateSpots -
-
{t('cardUi.event.pass')}
-
-
-
{t('cardUi.event.admits')}
-
- {e.holder || d.name} -
-
-
- - {/* Row 2: main body */} -
- -
-
- {e.image ? ( - - ) : ( -
🎟️
- )} -
-
- -
-
{e.title}
-
🗓️ {dateText}
- {e.location && ( -
📍 {e.location}
- )} - {typeof e.going === 'number' && e.going > 0 && ( -
{t('cardUi.event.going', { count: e.going })}
- )} -
- -
- -
- {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} -
-
-
- - {/* Bottom bar */} -
- - skate-spots.org · Powered by Nostr - - {t('cardUi.event.verifiedEvent')} -
-
- ); -} - -// ─── Card back side ─────────────────────────────────────────────────────────── -function RiderCardBack({ d }: { d: CardData }) { - const { t } = useTranslation(); - const crewNames = (d.crews ?? []).map(c => c.name); - const roleLabels = (d.roles ?? []).map(r => { - const m = roleMeta(r); - return m ? `${m.emoji} ${t(`cardUi.roles.${m.role}`)}` : null; - }).filter(Boolean) as string[]; - - return ( -
-
- 🛹 SkateSpots - {t('cardUi.back.title')} -
- -
-
- {crewNames.length > 0 && ( -
{t('cardUi.facets.crews')}: {crewNames.join(' · ')}
- )} - {roleLabels.length > 0 && ( -
{t('cardUi.facets.roles')}: {roleLabels.join(' · ')}
- )} - {d.local &&
📍 {d.local}
} - {d.ridesFor &&
🏪 {d.ridesFor}
} - {d.sponsors &&
🤝 {d.sponsors}
} - {passExpiry(d.validThru) !== 'none' && ( -
{t('cardUi.verify.validThru')}: {d.validThru}
- )} - {crewNames.length === 0 && roleLabels.length === 0 && !d.local && !d.ridesFor && !d.sponsors && ( -
{t('cardUi.back.empty')}
- )} -
- -
- -
- {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} -
-
-
- -
- skate-spots.org · Powered by Nostr - {shortNpub(d.pubkey)} -
-
- ); -} - -export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { - const themeClass = `card-theme-${d.theme}`; - const holoClass = d.holographic ? 'rider-card-holo' : ''; - const customClass = d.accent ? 'card-custom-accent' : ''; - const side = d.side ?? 'front'; - const vars = d.accent - ? ({ '--ss-accent': d.accent, '--ss-accent2': d.accent2 || d.accent } as React.CSSProperties) - : undefined; + }, 180); + return () => { cancelled = true; clearTimeout(handle); }; + }, [d]); return (
- {side === 'front' &&
} - {side === 'back' - ? - : <> - {d.type === 'rider' && } - {d.type === 'park' && } - {d.type === 'crew' && } - {d.type === 'event' && } - } + {src + ? Card preview + :
}
); } diff --git a/src/lib/riderCardCanvas.ts b/src/lib/riderCardCanvas.ts new file mode 100644 index 0000000..e055f5b --- /dev/null +++ b/src/lib/riderCardCanvas.ts @@ -0,0 +1,501 @@ +/** + * 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 — no second React + * implementation to drift from it. + * + * Design: "Credential" — a large portrait, one huge name, a single identity + * line, and a clean verify stub. Nothing else. (No tiny stats, no npub clutter, + * no "Powered by Nostr" filler.) + */ +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 = { + 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)' }, +}; + +export interface CanvasCrew { name: string } + +export interface CanvasCardData { + type: 'rider' | 'park' | 'crew' | 'event'; + themeId: string; + /** Custom accent hex overriding the theme's primary accent. */ + accentOverride?: string; + name: string; + nip05?: string; + avatarUrl?: string; + logoUrl?: string; + pubkey: string; + npub: string; + style: string; + level?: string; + sponsors: string; + ridesFor: string; + showSpots: boolean; + showZaps: boolean; + showEvents: boolean; + showCrews: boolean; + showLocal: boolean; + showRoles: boolean; + spotsAdded: number; + zapsReceived: number; + eventsCreated: number; + memberYear: string; + receivedBadges: { skill: string }[]; + verifyUrl?: string; + local?: string; + crews?: string[]; + roles?: string[]; + crew?: { + name: string; image?: string; pubkey: string; identifier: string; location?: string; + founded?: string; members: number; focus?: string; vibes: string[]; lookingFor: string[]; spots: number; + }; + event?: { + title: string; start: number; end?: number; location?: string; image?: string; + pubkey: string; identifier: string; going?: number; holder?: string; + }; + issuerName: string; + recipientName: string; + accessLevel: string; + validThru: string; + holographic?: boolean; +} + +// ─── helpers ────────────────────────────────────────────────────────────────── +async function loadImage(url: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + if (url.startsWith('data:') || url.startsWith('blob:')) { + img.src = url; + } else { + img.src = `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}`; + } + setTimeout(() => resolve(null), 5000); + }); +} + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +/** Shrink text until it fits maxWidth (adds an ellipsis if truncated). */ +function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { + let t = text; + while (ctx.measureText(t).width > maxWidth && t.length > 2) t = t.slice(0, -1); + return t.length < text.length ? t.slice(0, -1) + '…' : t; +} + +/** Draw an image covering a box (object-fit: cover), clipped to radius. */ +function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, r: 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(); + ctx.drawImage(img, dx, dy, dw, dh); + ctx.restore(); +} + +/** Letter-spaced uppercase text (ctx.letterSpacing is Chromium-supported). */ +function wordmark(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, spacing: number) { + const prev = (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing; + try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = `${spacing}px`; } catch { /* unsupported */ } + ctx.fillText(text, x, y); + try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = prev; } catch { /* ignore */ } +} + +// ─── shared blocks ──────────────────────────────────────────────────────────── + +const PAD = 40; +const PORTRAIT_W = 296; + +/** The brand lockup, top-left of the text column. */ +function drawBrand(ctx: CanvasRenderingContext2D, x: number, y: number, p: typeof THEME_PALETTE[string], kind: string) { + ctx.textAlign = 'left'; + ctx.font = 'bold 17px system-ui, sans-serif'; + ctx.fillStyle = p.accent; + wordmark(ctx, 'SKATESPOTS', x, y, 4); + ctx.font = 'bold 13px system-ui, sans-serif'; + ctx.fillStyle = p.muted; + wordmark(ctx, kind, x + 1, y + 24, 3); +} + +/** The QR / verify stub, bottom-right. */ +async function drawVerifyStub( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + verifyUrl: string | undefined, + p: typeof THEME_PALETTE[string], + dark = false, +) { + // panel + roundRect(ctx, x, y, size, size, 16); + ctx.fillStyle = dark ? 'rgba(255,255,255,0.06)' : '#ffffff'; + ctx.fill(); + ctx.strokeStyle = dark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.08)'; + ctx.lineWidth = 1; + ctx.stroke(); + + const pad = 16; + if (verifyUrl) { + try { + const QRCode = (await import('qrcode')).default; + const dataUrl = await QRCode.toDataURL(verifyUrl, { width: (size - pad * 2) * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } }); + const img = await loadImage(dataUrl); + if (img) ctx.drawImage(img, x + pad, y + pad, size - pad * 2, size - pad * 2); + } catch { /* skip */ } + } + + // caption under the stub + ctx.textAlign = 'center'; + ctx.font = 'bold 12px system-ui, sans-serif'; + ctx.fillStyle = p.muted; + wordmark(ctx, verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', x + size / 2, y + size + 20, 2); + ctx.textAlign = 'left'; +} + +// ─── front sides ────────────────────────────────────────────────────────────── + +async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) { + const RX = PORTRAIT_W + PAD; + const RW = CARD_W - RX - PAD; + + drawBrand(ctx, RX, 74, p, 'RIDER CARD'); + + // Name — the focal point. + ctx.fillStyle = p.text; + ctx.font = 'bold 82px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, data.name.toUpperCase(), RW), RX, 214); + + // One identity line: style · level · local. + const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · '); + let y = 262; + if (identity) { + ctx.fillStyle = p.accent; + ctx.font = 'bold 25px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, identity.toUpperCase(), RW), RX, y); + y += 40; + } + + // nip05 + if (data.nip05) { + ctx.fillStyle = p.muted; + ctx.font = 'italic 21px Georgia, serif'; + ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, RW), RX, y); + y += 34; + } else { + y += 8; + } + + // Crews (monogram circles) + roles, as one compact row. + 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}`); } + const witnessed = (data.receivedBadges ?? []).slice(0, 3).map(b => `★ ${b.skill}`); + const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : []; + + // crew monogram circles just above the bottom band + const circleR = 17; + let cx = RX + circleR; + crewNames.forEach((name) => { + ctx.beginPath(); + ctx.arc(cx, CARD_H - 66, circleR, 0, Math.PI * 2); + ctx.fillStyle = p.chipBg; + ctx.fill(); + ctx.strokeStyle = p.chipBorder; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.fillStyle = p.accent; + ctx.font = 'bold 16px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText((name[0] ?? '?').toUpperCase(), cx, CARD_H - 60); + ctx.textAlign = 'left'; + // name + ctx.fillStyle = p.muted; + ctx.font = '15px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, name, 96), cx + circleR + 8, CARD_H - 60); + cx += circleR * 2 + 16 + ctx.measureText(fitText(ctx, name, 96)).width + 12; + }); + + // roles + witnessed chips, right-aligned on the bottom band + const bottomChips = [...chips, ...witnessed]; + if (bottomChips.length > 0) { + ctx.textAlign = 'right'; + ctx.font = 'bold 15px system-ui, sans-serif'; + ctx.fillStyle = p.accent2; + ctx.fillText(fitText(ctx, bottomChips.join(' '), RW - 180), RX, CARD_H - 60); + ctx.textAlign = 'left'; + } + + // Crest logo top-left of the portrait (if any) + if (logo) drawCover(ctx, logo, 24, 24, 62, 62, 14); + + await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p); +} + +async function drawParkFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { + const RX = PORTRAIT_W + PAD; + const RW = CARD_W - RX - PAD; + const expired = passExpiry(data.validThru) === 'expired'; + + drawBrand(ctx, RX, 74, p, 'ACCESS PASS'); + + ctx.fillStyle = p.text; + ctx.font = 'bold 64px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), RW), RX, 186); + + ctx.fillStyle = expired ? '#ff5555' : p.accent; + ctx.font = 'bold 26px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), RW), RX, 234); + + ctx.fillStyle = p.muted; + ctx.font = '17px system-ui, sans-serif'; + ctx.fillText('ADMITS', RX, 296); + ctx.fillStyle = p.text; + ctx.font = 'bold 34px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, data.recipientName || data.name, RW), RX, 334); + + if (data.validThru) { + ctx.fillStyle = p.muted; + ctx.font = '16px system-ui, sans-serif'; + ctx.fillText(`VALID THRU ${data.validThru}`, RX, CARD_H - 60); + } + + await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p); +} + +async function drawCrewFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { + const RX = PORTRAIT_W + PAD; + const RW = CARD_W - RX - PAD; + const c = data.crew; + + drawBrand(ctx, RX, 74, p, 'CREW CARD'); + + ctx.fillStyle = p.text; + ctx.font = 'bold 68px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), RW), RX, 192); + + const line2 = [c?.location, c?.founded ? `EST. ${c.founded}` : ''].filter(Boolean).join(' · '); + if (line2) { + ctx.fillStyle = p.accent; + ctx.font = 'bold 24px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, line2.toUpperCase(), RW), RX, 240); + } + + if (c) { + ctx.fillStyle = p.muted; + ctx.font = '20px system-ui, sans-serif'; + ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, RX, 292); + if (c.lookingFor.length > 0) { + ctx.fillStyle = p.accent2; + ctx.font = 'bold 20px system-ui, sans-serif'; + ctx.fillText('OPEN CALL', RX, 328); + } + } + + await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p); +} + +async function drawEventFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { + const RX = PORTRAIT_W + PAD; + const RW = CARD_W - RX - PAD; + const e = data.event; + + drawBrand(ctx, RX, 74, p, 'EVENT PASS'); + + ctx.fillStyle = p.text; + ctx.font = 'bold 60px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), RW), RX, 184); + + if (e) { + ctx.fillStyle = p.accent; + ctx.font = 'bold 23px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), RW), RX, 228); + if (e.location) { + ctx.fillStyle = p.muted; + ctx.font = '19px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, `📍 ${e.location}`, RW), RX, 264); + } + } + + ctx.fillStyle = p.muted; + ctx.font = '17px system-ui, sans-serif'; + ctx.fillText('ADMITS', RX, 320); + ctx.fillStyle = p.text; + ctx.font = 'bold 32px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, e?.holder ?? data.name, RW), RX, 356); + + await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p); +} + +// ─── back ───────────────────────────────────────────────────────────────────── + +function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { + const lines: Array<[string, string]> = []; + const roles = (data.roles ?? []).map(r => roleMeta(r)).filter(Boolean).map(m => `${m!.emoji} ${m!.label}`).join(' · '); + if (data.crews?.length) lines.push(['CREWS', data.crews.join(' · ')]); + if (roles) lines.push(['LAB ROLES', roles]); + if (data.local) lines.push(['LOCAL', data.local]); + if (data.ridesFor) lines.push(['RIDES FOR', data.ridesFor]); + if (data.sponsors) lines.push(['SPONSORS', data.sponsors]); + if (data.validThru) lines.push(['VALID THRU', data.validThru]); + + drawBrand(ctx, PAD, 86, p, 'CARD BACK'); + + let y = 200; + for (const [label, value] of lines.slice(0, 6)) { + ctx.fillStyle = p.muted; + ctx.font = 'bold 15px system-ui, sans-serif'; + wordmark(ctx, label, PAD, y, 3); + ctx.fillStyle = p.text; + ctx.font = 'bold 26px system-ui, sans-serif'; + ctx.fillText(fitText(ctx, value, CARD_W - PAD * 2 - 220), PAD, y + 40); + y += 92; + } + if (lines.length === 0) { + ctx.fillStyle = p.muted; + ctx.font = '22px system-ui, sans-serif'; + ctx.fillText('Add crews, roles or sponsors to fill the back.', PAD, y); + } + + // Verify stub on the right + roundRect(ctx, CARD_W - PAD - 150, CARD_H - PAD - 150, 150, 150, 16); + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.fillStyle = p.muted; + ctx.font = 'bold 12px system-ui, sans-serif'; + ctx.textAlign = 'center'; + wordmark(ctx, 'skate-spots.org', CARD_W - PAD - 75, CARD_H - PAD + 22, 2); + ctx.textAlign = 'left'; +} + +// ─── entry point ────────────────────────────────────────────────────────────── + +export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'back' = 'front'): Promise { + const canvas = document.createElement('canvas'); + canvas.width = CARD_W; + canvas.height = CARD_H; + const ctx = canvas.getContext('2d')!; + + const theme = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug; + const p = data.accentOverride + ? { ...theme, accent: data.accentOverride, stripe1: data.accentOverride, stripe2: data.accentOverride } + : theme; + + if (side === 'back') { + ctx.fillStyle = '#0b0b0b'; + roundRect(ctx, 0, 0, CARD_W, CARD_H, 28); + ctx.fill(); + ctx.fillStyle = p.accent; + ctx.fillRect(0, 0, CARD_W, 8); + drawBack(ctx, data, p); + return canvas.toDataURL('image/png'); + } + + // Background + 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(); + + // Portrait panel (full height left) + 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); + 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); + ctx.fillStyle = p.accent; + ctx.font = '96px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 30); + ctx.textAlign = 'left'; + } + // darken the portrait's lower band so crew circles read + const shade = ctx.createLinearGradient(0, CARD_H - 200, 0, CARD_H); + shade.addColorStop(0, 'rgba(0,0,0,0)'); + shade.addColorStop(1, 'rgba(0,0,0,0.78)'); + ctx.fillStyle = shade; + ctx.fillRect(0, CARD_H - 200, PORTRAIT_W, 200); + ctx.restore(); + + // separator + ctx.strokeStyle = p.accent; + ctx.globalAlpha = 0.3; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(PORTRAIT_W, 0); ctx.lineTo(PORTRAIT_W, CARD_H); ctx.stroke(); + ctx.globalAlpha = 1; + + const logo = data.type === 'rider' && data.logoUrl ? await loadImage(data.logoUrl) : null; + + if (data.type === 'rider') await drawRiderFront(ctx, data, p, logo); + else if (data.type === 'park') await drawParkFront(ctx, data, p); + else if (data.type === 'crew') await drawCrewFront(ctx, data, p); + else await drawEventFront(ctx, data, p); + + // Signature bottom accent bar + const bar = ctx.createLinearGradient(0, 0, CARD_W, 0); + bar.addColorStop(0, p.stripe1); + bar.addColorStop(1, p.stripe2); + ctx.fillStyle = bar; + ctx.fillRect(0, CARD_H - 10, CARD_W, 10); + + // Holographic sheen (optional) — a soft diagonal highlight. + if (data.holographic) { + ctx.save(); + roundRect(ctx, 0, 0, CARD_W, CARD_H, 28); + ctx.clip(); + 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.58, 'rgba(255,255,255,0)'); + sheen.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = sheen; + ctx.fillRect(0, 0, CARD_W, CARD_H); + ctx.restore(); + } + + return canvas.toDataURL('image/png'); +} diff --git a/src/pages/PublicCardPage.tsx b/src/pages/PublicCardPage.tsx index 107e3e5..d46cb0c 100644 --- a/src/pages/PublicCardPage.tsx +++ b/src/pages/PublicCardPage.tsx @@ -19,7 +19,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useTranslation } from '@/contexts/language'; import { genUserName } from '@/lib/genUserName'; import { Button } from '@/components/ui/button'; -import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard'; +import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard'; import { RIDER_CARD_KIND, RIDER_CARD_D, DEFAULT_RIDER_CARD, cardVerifyUrl, memberSince } from '@/lib/riderCard'; function decodePubkey(value: string): string | null { @@ -96,7 +96,6 @@ export default function PublicCardPage() { receivedBadges, verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D), accent: config.accent || undefined, - accent2: config.accent || undefined, logo: config.logo || undefined, side, crews: memberships.map(m => ({ name: m.crew.name, image: m.crew.image })), @@ -122,7 +121,7 @@ export default function PublicCardPage() { className="w-full rounded-2xl flex items-center justify-center py-8 relative overflow-hidden" style={{ background: 'radial-gradient(ellipse at 50% 60%, rgba(255,0,64,0.08) 0%, rgba(0,0,0,0) 70%), #0a0a0a', border: '1px solid rgba(255,255,255,0.06)' }} > - +
{config.back && ( diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 47a1392..03142d8 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -20,8 +20,9 @@ import { nip19 } from 'nostr-tools'; // access at module load time (crashes the preview environment) import { LoginArea } from '@/components/auth/LoginArea'; -import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard'; -import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, roleMeta, formatCardDate, type ThemeId } from '@/lib/riderCard'; +import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard'; +import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas'; +import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -53,714 +54,6 @@ import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, S import type { NostrEvent } from '@nostrify/nostrify'; import { Link } from 'react-router-dom'; import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket, MoreHorizontal, Pencil, CheckCircle2, RefreshCw, Upload } from 'lucide-react'; -// ─── Card canvas renderer ───────────────────────────────────────────────────── -// Draws the card entirely via Canvas 2D API — 100% reliable export, -// no CSS capture, no scaling issues, no background-stripping. -// Card is 856×540px (10× credit-card mm, high-res for printing). - -const CARD_W = 856; -const CARD_H = 540; - -interface CanvasCardData { - type: 'rider' | 'park' | 'crew' | 'event'; - themeId: string; - name: string; - nip05?: string; - avatarUrl?: string; - pubkey: string; - npub: string; - style: string; - level?: string; - skills: string[]; - sponsors: string; - ridesFor: string; - showSpots: boolean; - showZaps: boolean; - showEvents: boolean; - spotsAdded: number; - zapsReceived: number; - eventsCreated: number; - memberYear: string; - receivedBadges: BadgeAward[]; - /** When set, the printed QR opens the verify page for this card. */ - verifyUrl?: string; - local?: string; - crews?: string[]; - roles?: string[]; - crew?: CardData['crew']; - event?: CardData['event']; - // Park - issuerName: string; - recipientName: string; - accessLevel: string; - validThru: string; -} - -// Theme palette for canvas drawing -const THEME_PALETTE: Record = { - 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 { - return new Promise((resolve) => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => resolve(img); - img.onerror = () => resolve(null); - // Only proxy actual HTTP URLs — data: URIs must never go through proxy - if (url.startsWith('data:') || url.startsWith('blob:')) { - img.src = url; - } else { - img.src = `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}`; - } - setTimeout(() => resolve(null), 5000); - }); -} - -// Draw text that wraps or truncates to fit maxWidth, returns lines drawn -function fillTextFit(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number): void { - let t = text; - while (ctx.measureText(t).width > maxWidth && t.length > 2) { - t = t.slice(0, -1); - } - if (t.length < text.length) t = t.slice(0, -1) + '…'; - ctx.fillText(t, x, y); -} - -// Draw a labeled row: small muted label above, value below -function _drawLabeledRow( - ctx: CanvasRenderingContext2D, - label: string, value: string, - x: number, y: number, maxW: number, - labelColor: string, valueColor: string, - labelSize: number, valueSize: number, - bold = false, -) { - ctx.fillStyle = labelColor; - ctx.font = `${labelSize}px system-ui, sans-serif`; - ctx.fillText(label.toUpperCase(), x, y); - ctx.fillStyle = valueColor; - ctx.font = `${bold ? 'bold ' : ''}${valueSize}px system-ui, sans-serif`; - fillTextFit(ctx, value, x, y + valueSize + 2, maxW); -} - -function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.lineTo(x + w - r, y); - ctx.quadraticCurveTo(x + w, y, x + w, y + r); - ctx.lineTo(x + w, y + h - r); - ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); - ctx.lineTo(x + r, y + h); - ctx.quadraticCurveTo(x, y + h, x, y + h - r); - ctx.lineTo(x, y + r); - ctx.quadraticCurveTo(x, y, x + r, y); - ctx.closePath(); -} - -/** The card back — printed in reverse (no QR; the front carries it). */ -function drawCardBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]): string { - ctx.fillStyle = '#0b0b0b'; - roundRect(ctx, 0, 0, CARD_W, CARD_H, 40); - ctx.fill(); - ctx.fillStyle = p.accent; - ctx.fillRect(0, 0, CARD_W, 10); - - ctx.fillStyle = p.accent; - ctx.font = 'bold 22px system-ui, sans-serif'; - ctx.fillText('🛹 SKATESPOTS', 40, 70); - ctx.fillStyle = p.muted; - ctx.font = '15px system-ui, sans-serif'; - ctx.fillText('CARD BACK', 40, 96); - - const roles = (data.roles ?? []).map(r => roleMeta(r)).filter(Boolean).map(m => `${m!.emoji} ${m!.label}`).join(' · '); - const lines: Array<[string, string]> = []; - if (data.crews?.length) lines.push(['CREWS', data.crews.join(' · ')]); - if (roles) lines.push(['LAB ROLES', roles]); - if (data.local) lines.push(['LOCAL', data.local]); - if (data.ridesFor) lines.push(['RIDES FOR', data.ridesFor]); - if (data.sponsors) lines.push(['SPONSORS', data.sponsors]); - if (data.validThru) lines.push(['VALID THRU', data.validThru]); - - let y = 168; - ctx.font = '15px system-ui, sans-serif'; - for (const [label, value] of lines.slice(0, 6)) { - ctx.fillStyle = p.muted; - ctx.fillText(label, 40, y); - ctx.fillStyle = p.text; - ctx.font = 'bold 20px system-ui, sans-serif'; - fillTextFit(ctx, value, 190, y, CARD_W - 230); - ctx.font = '15px system-ui, sans-serif'; - y += 44; - } - - ctx.fillStyle = p.muted; - ctx.font = '14px system-ui, sans-serif'; - ctx.fillText('SKATE-SPOTS.ORG · POWERED BY NOSTR', 40, CARD_H - 40); - - return ctx.canvas.toDataURL('image/png'); -} - -async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'back' = 'front'): Promise { - const canvas = document.createElement('canvas'); - canvas.width = CARD_W; - canvas.height = CARD_H; - const ctx = canvas.getContext('2d')!; - const p = THEME_PALETTE[data.themeId] ?? THEME_PALETTE.thug; - - if (side === 'back') return drawCardBack(ctx, data, p); - - // ─── 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 = bgGrad; - ctx.fill(); - - // ─── 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, 10); - - // ─── 4. QR code (top-right, generated first — data: URI safe) ──────────── - // The QR opens the card's verify page when published; otherwise the profile. - const qrUrl = data.verifyUrl || `https://skate-spots.org/${data.npub}`; - const qrSize = 168; - const qrX = CARD_W - 32 - qrSize; - const qrY = 28; - let qrImg: HTMLImageElement | null = null; - try { - // Dynamic import — avoids canvas API access at module load time - const QRCode = (await import('qrcode')).default; - const qrDataUrl = await QRCode.toDataURL(qrUrl, { - width: qrSize * 2, - margin: 2, - errorCorrectionLevel: 'M', - color: { dark: '#000000', light: '#ffffff' }, - }); - qrImg = await loadImage(qrDataUrl); // data: URI — no proxy - } catch { /* skip */ } - - if (qrImg) { - 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; - } - - // Level chip (derived from the Skater profile) - if (data.level) { - ctx.font = 'bold 16px system-ui, sans-serif'; - const lvlTxt = data.level.toUpperCase(); - const lvlW = ctx.measureText(lvlTxt).width + 24; - roundRect(ctx, RX, ry - 18, lvlW, 26, 6); - ctx.fillStyle = 'rgba(255,102,0,0.18)'; - ctx.fill(); - ctx.strokeStyle = 'rgba(255,102,0,0.45)'; - ctx.lineWidth = 1; - ctx.stroke(); - ctx.fillStyle = '#ff9944'; - ctx.fillText(lvlTxt, RX + 12, ry - 1); - ry += 22; - } - - // Local + crew crests (identity facets) - if (data.local) { - ctx.fillStyle = p.accent; - ctx.font = 'bold 17px system-ui, sans-serif'; - fillTextFit(ctx, `📍 ${data.local.toUpperCase()}`, RX, ry, RMaxW); - ry += 22; - } - if (data.crews && data.crews.length > 0) { - ctx.fillStyle = p.muted; - ctx.font = '14px system-ui, sans-serif'; - fillTextFit(ctx, `⛺ ${data.crews.slice(0, 3).join(' · ')}`, RX, ry, RMaxW); - ry += 20; - } - 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 || (data.roles?.length ?? 0) > 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.roles ?? []).slice(0, 3).map(r => { - const m = roleMeta(r); - return m ? { label: `${m.emoji} ${m.label}`, gold: false } : null; - }).filter(Boolean), - ...data.receivedBadges.slice(0, 4).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 if (data.type === 'park') { - // ═══════════════════════════════════════════════════════════════════════ - // 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); - } else if (data.type === 'crew') { - // ═══════════════════════════════════════════════════════════════════════ - // CREW LAYOUT - // ═══════════════════════════════════════════════════════════════════════ - const c = data.crew; - 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.fillStyle = p.accent; - ctx.font = '80px system-ui, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('🏕️', SIDEBAR_W / 2, avY + 118); - ctx.textAlign = 'left'; - } - - // Left: members + spots - ctx.textAlign = 'center'; - ctx.fillStyle = p.muted; - ctx.font = '12px system-ui, sans-serif'; - ctx.fillText('MEMBERS', SIDEBAR_W / 4 + 8, avY + avSize + 32); - ctx.fillText('SPOTS', (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 32); - ctx.fillStyle = p.accent; - ctx.font = 'bold 34px system-ui, sans-serif'; - ctx.fillText(String(c?.members ?? 0), SIDEBAR_W / 4 + 8, avY + avSize + 68); - ctx.fillStyle = p.accent2; - ctx.fillText(String(c?.spots ?? 0), (SIDEBAR_W * 3) / 4 - 8, avY + avSize + 68); - ctx.textAlign = 'left'; - - // Right zone - const RX = SIDEBAR_W + 28; - const RMaxW = qrX - RX - 20; - let ry = 52; - 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('CREW CARD', RX, ry + 14); - ry += 30; - ctx.fillStyle = p.text; - ctx.font = 'bold 46px system-ui, sans-serif'; - fillTextFit(ctx, (c?.name ?? '').toUpperCase(), RX, ry + 44, RMaxW); - ry += 56; - if (c?.location) { - ctx.fillStyle = p.accent; - ctx.font = 'bold 18px system-ui, sans-serif'; - fillTextFit(ctx, `📍 ${c.location}`, RX, ry, RMaxW); - ry += 24; - } - if (c?.founded) { - ctx.fillStyle = p.muted; - ctx.font = '13px system-ui, sans-serif'; - ctx.fillText(`EST. ${c.founded}`, RX, ry); - ry += 22; - } - if (c && c.lookingFor.length > 0) { - ctx.fillStyle = p.accent2; - ctx.font = 'bold 15px system-ui, sans-serif'; - ctx.fillText('OPEN CALL', RX, ry); - ry += 24; - } - if (c && c.vibes.length > 0) { - let chipX = RX; - let chipY = ry + 4; - ctx.font = 'bold 14px system-ui, sans-serif'; - c.vibes.slice(0, 5).forEach(v => { - const label = v.toUpperCase(); - const chipW = ctx.measureText(label).width + 18; - if (chipX + chipW > RX + RMaxW) { chipX = RX; chipY += 28; } - roundRect(ctx, chipX, chipY, chipW, 22, 5); - ctx.fillStyle = p.chipBg; - ctx.fill(); - ctx.strokeStyle = p.chipBorder; - ctx.lineWidth = 0.8; - ctx.stroke(); - ctx.fillStyle = p.accent; - ctx.fillText(label, chipX + 9, chipY + 16); - chipX += chipW + 6; - }); - } - - // Full npub - ctx.fillStyle = p.muted; - ctx.font = '12px monospace'; - ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64); - ctx.fillText(data.npub.slice(44), RX, CARD_H - 48); - } else { - // ═══════════════════════════════════════════════════════════════════════ - // EVENT PASS LAYOUT - // ═══════════════════════════════════════════════════════════════════════ - const e = data.event; - 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.fillStyle = p.accent; - ctx.font = '80px system-ui, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('🎟️', SIDEBAR_W / 2, avY + 118); - ctx.textAlign = 'left'; - } - - // Right zone - const RX = SIDEBAR_W + 28; - const RMaxW = qrX - RX - 20; - let ry = 52; - 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('EVENT PASS', RX, ry + 14); - ry += 30; - ctx.fillStyle = p.text; - ctx.font = 'bold 44px system-ui, sans-serif'; - fillTextFit(ctx, (e?.title ?? '').toUpperCase(), RX, ry + 42, RMaxW); - ry += 54; - if (e) { - ctx.fillStyle = p.accent; - ctx.font = 'bold 18px system-ui, sans-serif'; - fillTextFit(ctx, formatCardDate(e.start, e.end), RX, ry, RMaxW); - ry += 26; - if (e.location) { - ctx.fillStyle = p.muted; - ctx.font = '15px system-ui, sans-serif'; - fillTextFit(ctx, `📍 ${e.location}`, RX, ry, RMaxW); - ry += 24; - } - } - // Holder - ctx.fillStyle = p.muted; - ctx.font = '13px system-ui, sans-serif'; - ctx.fillText('ADMITS', RX, ry); - ry += 4; - ctx.fillStyle = p.accent2; - ctx.font = 'bold 22px system-ui, sans-serif'; - fillTextFit(ctx, e?.holder ?? '', RX, ry + 20, RMaxW); - ry += 34; - - // Full npub - ctx.fillStyle = p.muted; - ctx.font = '12px monospace'; - ctx.fillText(data.npub.slice(0, 44), RX, CARD_H - 64); - ctx.fillText(data.npub.slice(44), RX, CARD_H - 48); - } - - // ─── 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'); -} - // ─── Hook: fetch skill badges received by a pubkey ──────────────────────────── function useReceivedBadges(pubkey: string) { const { nostr } = useNostr(); @@ -896,7 +189,6 @@ export default function RiderCardPage() { const { mutate: publishEvent } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: uploadingLogo } = useUploadFile(); const { toast } = useToast(); - const printRef = useRef(null); const displayName = metadata?.name ?? genUserName(user?.pubkey ?? ''); const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : ''; @@ -1092,24 +384,44 @@ export default function RiderCardPage() { const renderCardToImage = useCallback(async (side: 'front' | 'back' = 'front'): Promise => { setRendering(true); try { - return await drawCardToCanvas({ - type: cardData.type, themeId: cardData.theme, - name: cardData.name, nip05: cardData.nip05, - avatarUrl: cardData.avatar, pubkey: cardData.pubkey, npub: cardData.npub, - style: cardData.style, skills: cardData.skills, - sponsors: cardData.sponsors, ridesFor: cardData.ridesFor, - showSpots: cardData.showSpots, showZaps: cardData.showZaps, showEvents: cardData.showEvents, - spotsAdded: cardData.spotsAdded, zapsReceived: cardData.zapsReceived, eventsCreated: cardData.eventsCreated, - memberYear: cardData.memberYear, receivedBadges: cardData.receivedBadges, + const canvasData: CanvasCardData = { + type: cardData.type, + themeId: cardData.theme, + accentOverride: cardData.accent, + name: cardData.name, + nip05: cardData.nip05, + avatarUrl: cardData.avatar, + logoUrl: cardData.logo, + pubkey: cardData.pubkey, + npub: cardData.npub, + style: cardData.style, + level: cardData.level, + sponsors: cardData.sponsors, + ridesFor: cardData.ridesFor, + showSpots: cardData.showSpots, + showZaps: cardData.showZaps, + showEvents: cardData.showEvents, + showCrews: cardData.showCrews ?? true, + showLocal: cardData.showLocal ?? true, + showRoles: cardData.showRoles ?? true, + spotsAdded: cardData.spotsAdded, + zapsReceived: cardData.zapsReceived, + eventsCreated: cardData.eventsCreated, + memberYear: cardData.memberYear, + receivedBadges: cardData.receivedBadges, verifyUrl: cardData.verifyUrl, local: cardData.local, crews: (cardData.crews ?? []).map(c => c.name), roles: cardData.roles, crew: cardData.crew, event: cardData.event, - issuerName: cardData.issuerName, recipientName: cardData.recipientName, - accessLevel: cardData.accessLevel, validThru: cardData.validThru, - }, side); + issuerName: cardData.issuerName, + recipientName: cardData.recipientName, + accessLevel: cardData.accessLevel, + validThru: cardData.validThru, + holographic: cardData.holographic, + }; + return await drawCardToCanvas(canvasData, side); } catch (err) { console.error('Card render failed:', err); return null; } finally { setRendering(false); } }, [cardData]); @@ -1270,7 +582,7 @@ export default function RiderCardPage() { className="absolute inset-x-0 top-1/2 h-32 -translate-y-1/2 opacity-30 blur-3xl" style={{ background: `radial-gradient(ellipse, ${theme === 'lime' ? '#ccff00' : theme === 'midnight' ? '#64a0ff' : theme === 'wood' ? '#f5c842' : theme === 'chrome' ? '#888' : theme === 'sunset' ? '#ff6020' : '#ff0040'}, transparent)` }} /> - +
{/* Flip front/back (personal card) */}