SkateSpots/src/components/RiderCard.tsx
SkateSpots 733dc5200e chore(card): audit cosmetics — zoom preview, translated title, drop legacy fields, clean URLs
- 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).
2026-10-02 14:53:12 +07:00

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>
)}
</>
);
}