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:
parent
40e62231b3
commit
733dc5200e
7 changed files with 60 additions and 33 deletions
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue