feat(card): redesign as a 'Credential' + unify to ONE canvas renderer

Design: the card read like a spec sheet (16 competing elements, 5-6px type).
Rebuilt as an editorial credential:
- Left full-height portrait panel; right column = brand, ONE huge name, a single
  identity line (style / level / local), nip05, crew monograms + witnessed
  badges, and a clean QR verify stub. Dropped the tiny stats row, npub clutter,
  'Powered by Nostr' filler and the 'SKATER LICENSE' chrome.
- Same system applied to Park / Crew / Event fronts; typed-up back side.

Engine: the studio preview and /card/:npub now render the canvas PNG, so there
is a SINGLE renderer (src/lib/riderCardCanvas.ts) — no React/canvas drift. The
old React card components + CardQr are deleted. Holographic is now a canvas
sheen. Also fixed several fillText calls missing an x-coord.
This commit is contained in:
SkateSpots 2026-10-02 11:57:32 +07:00
parent ce0e004a59
commit 84665adc8a
4 changed files with 604 additions and 1253 deletions

View file

@ -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<string | null>(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 (
<div className="rounded-[3px] flex-shrink-0" style={{ background: '#fff', padding: '3px', width: '44px', height: '44px' }}>
{src
? <img src={src} alt="QR" width={38} height={38} style={{ display: 'block', width: '38px', height: '38px' }} />
: <div style={{ width: '38px', height: '38px' }} />}
</div>
);
}
// ─── 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 (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* Row 1: header */}
<div className="flex items-start justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewLicense')}</div>
</div>
<div className="text-right">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
{shortNpub(d.pubkey)}
</div>
<div className="text-[5px] card-muted mt-[1px]">{t('labUi.rider.previewSince', { year: d.memberYear })}</div>
</div>
</div>
{/* Identity facets: local + crew crests */}
{((d.showLocal !== false && d.local) || (d.showCrews !== false && (d.crews?.length ?? 0) > 0)) && (
<div className="flex items-center gap-[6px] mt-[2px] overflow-hidden" style={{ height: '13px' }}>
{d.showLocal !== false && d.local && (
<span className="text-[6px] font-bold card-accent truncate flex-shrink-0" style={{ maxWidth: '150px' }}>📍 {d.local}</span>
)}
{d.showCrews !== false && (d.crews ?? []).slice(0, 3).map((c, i) => (
<span key={i} className="flex items-center gap-[2px] min-w-0 flex-shrink-0">
{c.image
? <img src={c.image} alt="" crossOrigin="anonymous" className="w-[11px] h-[11px] rounded-full object-cover border border-white/25" />
: <span className="w-[11px] h-[11px] rounded-full bg-white/15" />}
<span className="text-[5.5px] card-muted truncate" style={{ maxWidth: '66px' }}>{c.name}</span>
</span>
))}
</div>
)}
{/* Row 2: main body */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Avatar */}
<div className="flex-shrink-0 flex flex-col gap-[4px]">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
)}
</div>
<div className="space-y-[2px]">
{d.showSpots && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('labUi.pyramid.spots')}</span>
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
</div>
)}
{d.showZaps && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">⚡</span>
<span className="text-[7px] font-black card-accent2 leading-none">{d.zapsReceived}</span>
</div>
)}
{d.showEvents && (
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('labUi.rider.previewEvts')}</span>
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
</div>
)}
</div>
</div>
{/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="flex items-center gap-1.5 min-w-0">
{d.logo && (
<img src={d.logo} alt="" crossOrigin="anonymous" className="h-[13px] w-[13px] flex-shrink-0 rounded-[3px] object-cover border border-white/25" />
)}
<span className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</span>
</div>
{d.nip05 && (
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div>
)}
{d.style && (
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent w-fit">
{d.style}
</span>
)}
{d.level && (
<span className="text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] w-fit leading-none" style={{ background: 'rgba(255,102,0,0.18)', border: '1px solid rgba(255,102,0,0.45)', color: '#ff9944' }}>
{d.level}
</span>
)}
{d.ridesFor && (
<div className="text-[5.5px] card-muted truncate leading-none">🏪 {d.ridesFor}</div>
)}
{d.sponsors && (
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
)}
{/* Roles + witnessed badges only — self-claimed skills are gone */}
<div className="flex flex-wrap gap-[2px] mt-[2px]">
{d.showRoles !== false && (d.roles ?? []).slice(0, 3).map(r => {
const m = roleMeta(r);
return m ? (
<span key={r} className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none" style={{ background: 'rgba(100,160,255,0.18)', border: '1px solid rgba(100,160,255,0.45)', color: '#9cc4ff' }}>
{m.emoji} {t(`cardUi.roles.${m.role}`)}
</span>
) : null;
})}
{witnessedBadges.map((b, i) => {
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
return s ? (
<span
key={`w${i}`}
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none"
style={{ background: 'rgba(255,215,0,0.2)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
>
⭐ {s.label}
</span>
) : null;
})}
</div>
</div>
{/* QR */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
{/* Bottom bar */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5px] font-mono" style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shortNpub(d.pubkey)}
</span>
</div>
</div>
);
}
// ─── Card visual: Park Access front ──────────────────────────────────────────
function ParkFront({ d }: { d: CardData }) {
const { t } = useTranslation();
return (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
<div className="flex items-center justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')}
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewAccessPass')}</div>
</div>
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
{passExpiry(d.validThru) === 'expired'
? <span className="text-[7px] font-black uppercase" style={{ color: '#ff4d4d' }}>{t('cardUi.expired')}</span>
: <span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>}
</div>
</div>
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
<div className="flex-shrink-0">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{d.avatar ? (
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
)}
</div>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || t('labUi.rider.previewMemberName')}</div>
<div className="text-[5.5px] card-muted font-mono leading-none truncate">{shortNpub(d.pubkey)}</div>
<div className="text-[5.5px] card-muted leading-none">{t('labUi.rider.previewIssuedBy')}</div>
{d.validThru && (
<div className="flex items-center gap-1 mt-[3px]">
<span className="text-[5.5px] card-muted uppercase">{t('labUi.rider.previewValidThru')}</span>
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
</div>
)}
</div>
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<div className="flex items-center gap-1">
<span className="card-muted text-[5px] uppercase tracking-widest">{t('labUi.rider.previewNostrVerified')}</span>
</div>
</div>
</div>
);
}
// ─── Card visual: Crew front ─────────────────────────────────────────────────
function CrewFront({ d }: { d: CardData }) {
const { t } = useTranslation();
const c = d.crew;
if (!c) return null;
return (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* Row 1: header */}
<div className="flex items-start justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('cardUi.crew.crewCard')}</div>
</div>
<div className="text-right">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
{shortNpub(c.pubkey)}
</div>
{c.founded && <div className="text-[5px] card-muted mt-[1px]">{t('cardUi.crew.est', { year: c.founded })}</div>}
</div>
</div>
{/* Row 2: main body */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
{/* Logo + stats */}
<div className="flex-shrink-0 flex flex-col gap-[4px]">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{c.image ? (
<img src={c.image} alt={c.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🏕️</div>
)}
</div>
<div className="space-y-[2px]">
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('cardUi.crew.members')}</span>
<span className="text-[7px] font-black card-accent leading-none">{c.members}</span>
</div>
<div className="flex items-center justify-between gap-1">
<span className="text-[5px] card-muted">{t('cardUi.crew.spots')}</span>
<span className="text-[7px] font-black card-accent2 leading-none">{c.spots}</span>
</div>
</div>
</div>
{/* Identity block */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[14px] font-black uppercase truncate leading-tight">{c.name}</div>
{c.location && (
<div className="text-[6.5px] card-accent truncate leading-none">📍 {c.location}</div>
)}
{c.lookingFor.length > 0 && (
<span
className="text-[5px] font-black uppercase px-[4px] py-[1px] rounded-[3px] w-fit leading-none"
style={{ background: 'rgba(204,255,0,0.2)', border: '1px solid rgba(204,255,0,0.5)', color: '#ccff00' }}
>
{t('cardUi.crew.openCall')}
</span>
)}
<div className="flex flex-wrap gap-[2px] mt-[2px]">
{c.vibes.slice(0, 5).map(v => (
<span key={v} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
{v}
</span>
))}
</div>
</div>
{/* QR */}
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
{/* Bottom bar */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5px] uppercase tracking-widest">{t('cardUi.crew.verifiedCrew')}</span>
</div>
</div>
);
}
// ─── 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 (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
{/* Row 1: header */}
<div className="flex items-start justify-between" style={{ height: '26px' }}>
<div>
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots
</div>
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('cardUi.event.pass')}</div>
</div>
<div className="text-right min-w-0">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">{t('cardUi.event.admits')}</div>
<div className="text-[7px] font-black card-accent uppercase leading-none mt-[1px] truncate" style={{ maxWidth: '120px' }}>
{e.holder || d.name}
</div>
</div>
</div>
{/* Row 2: main body */}
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
<div className="flex-shrink-0">
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
{e.image ? (
<img src={e.image} alt="" className="w-full h-full object-cover" crossOrigin="anonymous" />
) : (
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🎟️</div>
)}
</div>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
<div className="text-[14px] font-black uppercase truncate leading-tight">{e.title}</div>
<div className="text-[6.5px] card-accent truncate leading-none">🗓️ {dateText}</div>
{e.location && (
<div className="text-[5.5px] card-muted truncate leading-none">📍 {e.location}</div>
)}
{typeof e.going === 'number' && e.going > 0 && (
<div className="text-[5.5px] card-muted leading-none">{t('cardUi.event.going', { count: e.going })}</div>
)}
</div>
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
{/* Bottom bar */}
<div
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
style={{ bottom: '6px' }}
>
<span className="card-muted text-[5px] uppercase tracking-widest">
skate-spots.org · Powered by Nostr
</span>
<span className="card-muted text-[5px] uppercase tracking-widest">{t('cardUi.event.verifiedEvent')}</span>
</div>
</div>
);
}
// ─── 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 (
<div className="absolute inset-0" style={{ padding: '10px 10px 16px 10px' }}>
<div className="flex items-center justify-between" style={{ height: '24px' }}>
<span className="text-[8px] font-black uppercase tracking-[0.18em] card-accent">🛹 SkateSpots</span>
<span className="text-[6px] card-muted uppercase tracking-widest">{t('cardUi.back.title')}</span>
</div>
<div className="mt-[4px] flex gap-[8px]" style={{ height: '120px' }}>
<div className="flex-1 min-w-0 flex flex-col gap-[3px] text-[6.5px] leading-tight">
{crewNames.length > 0 && (
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.facets.crews')}: </span><span className="font-bold">{crewNames.join(' · ')}</span></div>
)}
{roleLabels.length > 0 && (
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.facets.roles')}: </span><span className="font-bold">{roleLabels.join(' · ')}</span></div>
)}
{d.local && <div className="truncate">📍 <span className="font-bold">{d.local}</span></div>}
{d.ridesFor && <div className="truncate">🏪 <span className="font-bold">{d.ridesFor}</span></div>}
{d.sponsors && <div className="truncate">🤝 <span className="font-bold">{d.sponsors}</span></div>}
{passExpiry(d.validThru) !== 'none' && (
<div className="truncate"><span className="card-muted uppercase">{t('cardUi.verify.validThru')}: </span><span className="font-bold">{d.validThru}</span></div>
)}
{crewNames.length === 0 && roleLabels.length === 0 && !d.local && !d.ridesFor && !d.sponsors && (
<div className="card-muted">{t('cardUi.back.empty')}</div>
)}
</div>
<div className="flex-shrink-0 flex flex-col items-end justify-end">
<CardQr url={d.verifyUrl} />
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
</div>
</div>
</div>
<div className="absolute left-0 right-0 flex items-center justify-between px-[10px]" style={{ bottom: '6px' }}>
<span className="card-muted text-[5px] uppercase tracking-widest">skate-spots.org · Powered by Nostr</span>
<span className="card-muted text-[5px] font-mono">{shortNpub(d.pubkey)}</span>
</div>
</div>
);
}
export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
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 (
<div
ref={printRef}
className={cn('rider-card', themeClass, holoClass, customClass, side === 'back' && 'rider-card-back', 'select-none')}
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif", ...vars }}
className={cn('w-full max-w-[440px] overflow-hidden rounded-2xl', className)}
style={{ aspectRatio: '856 / 540' }}
>
{side === 'front' && <div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />}
{side === 'back'
? <RiderCardBack d={d} />
: <>
{d.type === 'rider' && <RiderFront d={d} />}
{d.type === 'park' && <ParkFront d={d} />}
{d.type === 'crew' && <CrewFront d={d} />}
{d.type === 'event' && <EventFront d={d} />}
</>}
{src
? <img src={src} alt="Card preview" className="h-full w-full object-cover" style={{ display: 'block' }} />
: <div className="h-full w-full animate-pulse rounded-2xl bg-white/5" />}
</div>
);
}

501
src/lib/riderCardCanvas.ts Normal file
View file

@ -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<string, {
bg1: string; bg2: string; bg3: string;
accent: string; accent2: string;
stripe1: string; stripe2: string;
muted: string; chipBg: string; chipBorder: 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)' },
};
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<HTMLImageElement | null> {
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<string> {
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');
}

View file

@ -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)' }}
>
<RiderCardFront d={cardData} />
<CardPreview d={cardData} />
</div>
{config.back && (

View file

@ -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<string, {
bg1: string; bg2: string; bg3: string;
accent: string; accent2: string;
stripe1: string; stripe2: string;
muted: string; chipBg: string; chipBorder: 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.5)', chipBg: 'rgba(255,0,64,0.18)', chipBorder: 'rgba(255,0,64,0.45)', text: '#ffffff', shopColor: '#ccff00', sponsorColor: '#ff9900', sidebarBg: 'rgba(255,0,64,0.07)' },
lime: { bg1: '#040c00', bg2: '#081500', bg3: '#040c00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.5)', chipBg: 'rgba(204,255,0,0.14)', chipBorder: 'rgba(204,255,0,0.4)', text: '#ffffff', shopColor: '#ff6600', sponsorColor: '#ff0040', sidebarBg: 'rgba(204,255,0,0.05)' },
chrome: { bg1: '#111111', bg2: '#252525', bg3: '#0e0e0e', accent: '#d0d0d0', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.55)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#e8e8e8', shopColor: '#ff9900', sponsorColor: '#64cfff', sidebarBg: 'rgba(200,200,200,0.04)' },
wood: { bg1: '#2e1804', bg2: '#4a2a0c', bg3: '#261404', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.6)', chipBg: 'rgba(160,100,40,0.25)', chipBorder: 'rgba(200,140,60,0.5)', text: '#f5e6c8', shopColor: '#ff9944', sponsorColor: '#aaffcc', sidebarBg: 'rgba(160,100,40,0.12)' },
midnight: { bg1: '#010610', bg2: '#040c22', bg3: '#020814', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#2040aa', stripe2: '#64a0ff', muted: 'rgba(200,220,255,0.55)', chipBg: 'rgba(100,160,255,0.12)', chipBorder: 'rgba(100,160,255,0.35)', text: '#d0e4ff', shopColor: '#ccff00', sponsorColor: '#ff88cc', sidebarBg: 'rgba(100,160,255,0.05)' },
sunset: { bg1: '#0e0004', bg2: '#160606', bg3: '#0a040c', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,200,150,0.6)', chipBg: 'rgba(255,80,0,0.16)', chipBorder: 'rgba(255,80,0,0.4)', text: '#ffd0a0', shopColor: '#ffdd00', sponsorColor: '#ff88ff', sidebarBg: 'rgba(255,80,0,0.07)' },
};
async function loadImage(url: string): Promise<HTMLImageElement | null> {
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<string> {
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<HTMLDivElement>(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<string | null> => {
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)` }}
/>
<RiderCardFront d={cardData} printRef={printRef} />
<CardPreview d={cardData} />
</div>
{/* Flip front/back (personal card) */}