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).
This commit is contained in:
SkateSpots 2026-10-02 14:53:12 +07:00
parent 40e62231b3
commit 733dc5200e
7 changed files with 60 additions and 33 deletions

View file

@ -7,6 +7,7 @@
* 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';
@ -30,7 +31,6 @@ export interface CardData {
npub: string;
style: string;
level?: string;
skills: string[];
sponsors: string;
ridesFor: string;
showCrews?: boolean;
@ -89,8 +89,9 @@ function toCanvasData(d: CardData): CanvasCardData {
* 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 }) {
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;
@ -106,13 +107,38 @@ export function CardPreview({ d, className }: { d: CardData; className?: string
}, [d]);
return (
<div
className={cn('w-full max-w-[440px] overflow-hidden rounded-2xl', className)}
style={{ aspectRatio: '856 / 540' }}
>
{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>
<>
<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>
)}
</>
);
}

View file

@ -6325,6 +6325,8 @@ walletUi: {
savedDesc: 'Your card is now signed and verifiable.',
saveFailed: 'Could not save the card',
notPublished: 'Not published',
titleNeon: 'Rider',
titleRest: 'Card',
saveFrontPng: 'Save front (PNG)',
expired: 'EXPIRED',
logoUploaded: 'Logo uploaded',

View file

@ -6325,6 +6325,8 @@ walletUi: {
savedDesc: 'Tu carné está firmado y se puede verificar.',
saveFailed: 'No se pudo guardar el carné',
notPublished: 'Sin publicar',
titleNeon: 'Carné de',
titleRest: 'Rider',
saveFrontPng: 'Guardar anverso (PNG)',
expired: 'CADUCADO',
logoUploaded: 'Logo subido',

View file

@ -23,8 +23,6 @@ export const CARD_ORIGIN = 'https://skate-spots.org';
export interface RiderCardConfig {
theme: string;
style: string;
skills: string[];
sponsors: string;
ridesFor: string;
showCrews: boolean;
@ -42,8 +40,6 @@ export interface RiderCardConfig {
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
theme: 'thug',
style: '',
skills: [],
sponsors: '',
ridesFor: '',
showCrews: true,
@ -62,8 +58,6 @@ export function parseRiderCard(event: NostrEvent): RiderCardConfig {
const show = new Set(event.tags.filter(([n]) => n === 'show').map(([, v]) => v));
return {
theme: tag('theme') || DEFAULT_RIDER_CARD.theme,
style: tag('style') || DEFAULT_RIDER_CARD.style,
skills: event.tags.filter(([n]) => n === 'skill').map(([, v]) => v).filter(Boolean),
sponsors: tag('sponsor'),
ridesFor: tag('rides-for'),
// Facets arrived in v2; an older card defaults them ON rather than hiding them.
@ -87,8 +81,6 @@ export function riderCardTags(c: RiderCardConfig): string[][] {
['theme', c.theme],
['v', '3'],
];
if (c.style.trim()) tags.push(['style', c.style.trim()]);
for (const s of c.skills) tags.push(['skill', s]);
if (c.sponsors.trim()) tags.push(['sponsor', c.sponsors.trim()]);
if (c.ridesFor.trim()) tags.push(['rides-for', c.ridesFor.trim()]);
if (c.accent.trim()) tags.push(['accent', c.accent.trim()]);
@ -117,14 +109,14 @@ export function cardNaddr(kind: number, pubkey: string, d: string): string {
return nip19.naddrEncode({ kind, pubkey, identifier: d });
}
/** HashRouter URL that opens the verify page for a card. */
/** Clean URL that opens the verify page for a card (a startup shim maps it to the hash). */
export function cardVerifyUrl(kind: number, pubkey: string, d: string): string {
return `${CARD_ORIGIN}/#/verify/${cardNaddr(kind, pubkey, d)}`;
return `${CARD_ORIGIN}/verify/${cardNaddr(kind, pubkey, d)}`;
}
/** HashRouter URL for a public card page. */
/** Clean URL for a public card page. */
export function cardPublicUrl(pubkey: string): string {
return `${CARD_ORIGIN}/#/card/${nip19.npubEncode(pubkey)}`;
return `${CARD_ORIGIN}/card/${nip19.npubEncode(pubkey)}`;
}
// ─── View presets (shared by the editor + the public card) ────────────────────

View file

@ -16,6 +16,13 @@ if (typeof window !== 'undefined') {
}
}
// Clean share links: a bare /verify/<naddr> or /card/<npub> hits the Netlify SPA
// fallback (index.html), but HashRouter needs the route in the hash — rewrite it.
if (typeof window !== 'undefined' && !window.location.hash) {
const m = window.location.pathname.match(/^\/(verify|card)\/(.+)$/);
if (m) window.location.replace(`/#/${m[1]}/${m[2]}${window.location.search}`);
}
// Outfit font for body text
import '@fontsource-variable/outfit';
// Bebas Neue — bold condensed display font for headlines

View file

@ -87,7 +87,6 @@ export default function PublicCardPage() {
npub: nip19.npubEncode(pubkey),
style: derivedStyle,
level: derivedLevel || undefined,
skills: config.skills,
sponsors: config.sponsors,
ridesFor: config.ridesFor,
showCrews: config.showCrews,
@ -121,7 +120,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)' }}
>
<CardPreview d={cardData} />
<CardPreview d={cardData} zoomable />
</div>
{config.back && (

View file

@ -343,7 +343,6 @@ export default function RiderCardPage() {
npub: cardPubkey ? fullNpub(cardPubkey) : '',
style: derivedStyle,
level: derivedLevel || undefined,
skills: [],
accent: accent || undefined,
accent2: accent || undefined,
logo: logo || undefined,
@ -407,7 +406,7 @@ export default function RiderCardPage() {
name: displayName,
pubkey: selectedPass.event.pubkey,
npub: '',
style: '', skills: [], sponsors: '', ridesFor: '',
style: '', sponsors: '', ridesFor: '',
receivedBadges: [],
verifyUrl: cardVerifyUrl(PARK_ACCESS_CARD_KIND, selectedPass.event.pubkey, selectedPass.d),
issuerName: selectedPass.issuer,
@ -479,7 +478,7 @@ export default function RiderCardPage() {
const handleSaveCard = useCallback(() => {
if (!user) return;
const config: RiderCardConfig = { theme, style: '', skills: [], sponsors, ridesFor, showCrews, showLocal, showRoles, accent, logo, back, holographic };
const config: RiderCardConfig = { theme, sponsors, ridesFor, showCrews, showLocal, showRoles, accent, logo, back, holographic };
publishEvent(
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) },
{
@ -592,8 +591,8 @@ export default function RiderCardPage() {
<div className="relative text-7xl mb-4">🛹</div>
</div>
<h1 className="text-4xl font-black uppercase mb-2">
<span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span>
<span className="neon-red">{t('cardUi.titleNeon')}</span>
<span className="text-white"> {t('cardUi.titleRest')}</span>
</h1>
<p className="text-gray-400 max-w-sm mb-2">
{t('labUi.rider.heroDesc')}
@ -614,8 +613,8 @@ export default function RiderCardPage() {
{/* ── Page header ── */}
<div className="pt-8 pb-6 text-center">
<h1 className="text-3xl font-black uppercase tracking-tight mb-1">
<span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span>
<span className="neon-red">{t('cardUi.titleNeon')}</span>
<span className="text-white"> {t('cardUi.titleRest')}</span>
</h1>
<p className="text-gray-500 text-xs tracking-widest uppercase">{t('labUi.rider.headerTagline')}</p>
</div>
@ -654,7 +653,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)` }}
/>
<CardPreview d={cardData} />
<CardPreview d={cardData} zoomable />
</div>
{/* Flip front/back (personal card) */}
@ -1200,7 +1199,7 @@ export default function RiderCardPage() {
{passCardData && (
<div className="space-y-4">
<div className="flex justify-center">
<CardPreview d={passCardData} />
<CardPreview d={passCardData} zoomable />
</div>
<Button
onClick={handlePrintPass}