- U2: CardPreview is zoomable (click for a full-screen overlay); used in the studio, 'My passes' and the public card. - U4: the page title is translated (Rider Card / Carné de Rider) — was hardcoded English. - L4: removed the legacy style/skills from RiderCardConfig + CardData (the card derives style/level and shows only witnessed badges). - I2: cardVerifyUrl/cardPublicUrl are clean (/verify/<naddr>, /card/<npub>) and a startup shim rewrites those paths to the hash (old /#/ links still work).
144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
/**
|
|
* 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 { Maximize2 } from 'lucide-react';
|
|
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
|
|
import type { ThemeId } from '@/lib/riderCard';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface BadgeAward {
|
|
witnessName: string;
|
|
witnessPubkey: string;
|
|
skill: string;
|
|
spotName?: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface CardData {
|
|
type: 'rider' | 'park' | 'crew' | 'event';
|
|
theme: ThemeId;
|
|
holographic: boolean;
|
|
name: string;
|
|
nip05?: string;
|
|
avatar?: string;
|
|
pubkey: string;
|
|
npub: string;
|
|
style: string;
|
|
level?: string;
|
|
sponsors: string;
|
|
ridesFor: string;
|
|
showCrews?: boolean;
|
|
showLocal?: boolean;
|
|
showRoles?: boolean;
|
|
receivedBadges: BadgeAward[];
|
|
verifyUrl?: string;
|
|
accent?: string;
|
|
logo?: string;
|
|
side?: 'front' | 'back';
|
|
crews?: { name: string; image?: string }[];
|
|
local?: string;
|
|
roles?: string[];
|
|
crew?: CanvasCardData['crew'];
|
|
event?: CanvasCardData['event'];
|
|
issuerName: string;
|
|
recipientName: string;
|
|
accessLevel: string;
|
|
validThru: 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,
|
|
showCrews: d.showCrews ?? true,
|
|
showLocal: d.showLocal ?? true,
|
|
showRoles: d.showRoles ?? true,
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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, zoomable = false }: { d: CardData; className?: string; zoomable?: boolean }) {
|
|
const [src, setSrc] = useState<string | null>(null);
|
|
const [zoom, setZoom] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const handle = setTimeout(async () => {
|
|
try {
|
|
const url = await drawCardToCanvas(toCanvasData(d), d.side ?? 'front');
|
|
if (!cancelled) setSrc(url);
|
|
} catch {
|
|
if (!cancelled) setSrc(null);
|
|
}
|
|
}, 180);
|
|
return () => { cancelled = true; clearTimeout(handle); };
|
|
}, [d]);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={zoomable && src ? () => setZoom(true) : undefined}
|
|
className={cn(
|
|
'group relative w-full max-w-[440px] overflow-hidden rounded-2xl',
|
|
zoomable && src && 'cursor-zoom-in',
|
|
className,
|
|
)}
|
|
style={{ aspectRatio: '856 / 540' }}
|
|
aria-label={zoomable ? 'Zoom card' : undefined}
|
|
>
|
|
{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" />}
|
|
{zoomable && src && (
|
|
<span className="absolute bottom-2 right-2 rounded-full bg-black/60 p-1.5 text-white opacity-0 transition-opacity group-hover:opacity-100">
|
|
<Maximize2 className="h-3.5 w-3.5" />
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{zoom && src && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
|
|
onClick={() => setZoom(false)}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<img src={src} alt="Card" className="max-h-[92vh] w-auto max-w-[95vw] rounded-2xl shadow-2xl" />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|