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. * implementation of the card.
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Maximize2 } from 'lucide-react';
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas'; import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
import type { ThemeId } from '@/lib/riderCard'; import type { ThemeId } from '@/lib/riderCard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -30,7 +31,6 @@ export interface CardData {
npub: string; npub: string;
style: string; style: string;
level?: string; level?: string;
skills: string[];
sponsors: string; sponsors: string;
ridesFor: string; ridesFor: string;
showCrews?: boolean; 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 * Live canvas-backed card preview. Debounced so a fast typist doesn't kick off
* a canvas + QR render on every keystroke. * 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 [src, setSrc] = useState<string | null>(null);
const [zoom, setZoom] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -106,13 +107,38 @@ export function CardPreview({ d, className }: { d: CardData; className?: string
}, [d]); }, [d]);
return ( return (
<div <>
className={cn('w-full max-w-[440px] overflow-hidden rounded-2xl', className)} <button
style={{ aspectRatio: '856 / 540' }} type="button"
> onClick={zoomable && src ? () => setZoom(true) : undefined}
{src className={cn(
? <img src={src} alt="Card preview" className="h-full w-full object-cover" style={{ display: 'block' }} /> 'group relative w-full max-w-[440px] overflow-hidden rounded-2xl',
: <div className="h-full w-full animate-pulse rounded-2xl bg-white/5" />} zoomable && src && 'cursor-zoom-in',
</div> 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.', savedDesc: 'Your card is now signed and verifiable.',
saveFailed: 'Could not save the card', saveFailed: 'Could not save the card',
notPublished: 'Not published', notPublished: 'Not published',
titleNeon: 'Rider',
titleRest: 'Card',
saveFrontPng: 'Save front (PNG)', saveFrontPng: 'Save front (PNG)',
expired: 'EXPIRED', expired: 'EXPIRED',
logoUploaded: 'Logo uploaded', logoUploaded: 'Logo uploaded',

View file

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

View file

@ -23,8 +23,6 @@ export const CARD_ORIGIN = 'https://skate-spots.org';
export interface RiderCardConfig { export interface RiderCardConfig {
theme: string; theme: string;
style: string;
skills: string[];
sponsors: string; sponsors: string;
ridesFor: string; ridesFor: string;
showCrews: boolean; showCrews: boolean;
@ -42,8 +40,6 @@ export interface RiderCardConfig {
export const DEFAULT_RIDER_CARD: RiderCardConfig = { export const DEFAULT_RIDER_CARD: RiderCardConfig = {
theme: 'thug', theme: 'thug',
style: '',
skills: [],
sponsors: '', sponsors: '',
ridesFor: '', ridesFor: '',
showCrews: true, 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)); const show = new Set(event.tags.filter(([n]) => n === 'show').map(([, v]) => v));
return { return {
theme: tag('theme') || DEFAULT_RIDER_CARD.theme, 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'), sponsors: tag('sponsor'),
ridesFor: tag('rides-for'), ridesFor: tag('rides-for'),
// Facets arrived in v2; an older card defaults them ON rather than hiding them. // 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], ['theme', c.theme],
['v', '3'], ['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.sponsors.trim()) tags.push(['sponsor', c.sponsors.trim()]);
if (c.ridesFor.trim()) tags.push(['rides-for', c.ridesFor.trim()]); if (c.ridesFor.trim()) tags.push(['rides-for', c.ridesFor.trim()]);
if (c.accent.trim()) tags.push(['accent', c.accent.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 }); 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 { 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 { 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 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 // Outfit font for body text
import '@fontsource-variable/outfit'; import '@fontsource-variable/outfit';
// Bebas Neue — bold condensed display font for headlines // Bebas Neue — bold condensed display font for headlines

View file

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

View file

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