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.
|
* 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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) ────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue