fix(card): audit fixes — share link, dup passes, dead stats, public style, holo persist

- B1: Share's clipboard fallback copied https://…/<npub> (a path HashRouter
  ignores → homepage). Now /#/card/<npub> (native share text too).
- B4: useIssuedPasses didn't dedupe by d, so a revoked pass showed twice;
  now newest-per-d like useReceivedPasses.
- B2/L5: the redesign dropped the stats row, so Spots/Zaps/Events toggles +
  useRiderStats + spotsAdded/zapsReceived/eventsCreated/memberYear were dead.
  Removed across lib/riderCard, CardData, CanvasCardData, both pages.
- B3: PublicCardPage ignored derived style/level (config.style is '' now) — it
  now derives them from the Skater profile like the studio; holographic honors
  the saved config.
- B6: holographic wasn't persisted — added to the card config/tags/parse.
- B5: 'Save PNG' is front-only while Print is both sides → label it
  'Save front (PNG)' when a back exists.
This commit is contained in:
SkateSpots 2026-10-02 14:43:43 +07:00
parent 8dcb19eba6
commit ec02e4fe71
8 changed files with 48 additions and 93 deletions

View file

@ -33,13 +33,9 @@ export interface CardData {
skills: string[];
sponsors: string;
ridesFor: string;
showSpots: boolean;
showZaps: boolean;
showEvents: boolean;
spotsAdded: number;
zapsReceived: number;
eventsCreated: number;
memberYear: string;
showCrews?: boolean;
showLocal?: boolean;
showRoles?: boolean;
receivedBadges: BadgeAward[];
verifyUrl?: string;
accent?: string;
@ -48,11 +44,9 @@ export interface CardData {
crews?: { name: string; image?: string }[];
local?: string;
roles?: string[];
showCrews?: boolean;
showLocal?: boolean;
showRoles?: boolean;
crew?: CanvasCardData['crew'];
event?: CanvasCardData['event']; issuerName: string;
event?: CanvasCardData['event'];
issuerName: string;
recipientName: string;
accessLevel: string;
validThru: string;
@ -73,16 +67,9 @@ function toCanvasData(d: CardData): CanvasCardData {
level: d.level,
sponsors: d.sponsors,
ridesFor: d.ridesFor,
showSpots: d.showSpots,
showZaps: d.showZaps,
showEvents: d.showEvents,
showCrews: d.showCrews ?? true,
showLocal: d.showLocal ?? true,
showRoles: d.showRoles ?? true,
spotsAdded: d.spotsAdded,
zapsReceived: d.zapsReceived,
eventsCreated: d.eventsCreated,
memberYear: d.memberYear,
receivedBadges: d.receivedBadges,
verifyUrl: d.verifyUrl,
local: d.local,

View file

@ -179,7 +179,15 @@ export function useIssuedPasses(pubkey?: string) {
[{ kinds: [PARK_ACCESS_CARD_KIND], authors: [pubkey], limit: 100 }],
{ signal },
);
return events
// Newest version per d-tag — a revoke re-publishes the SAME d, so without
// this the issued list would show both the original and the revoked pass.
const byD = new Map<string, NostrEvent>();
for (const ev of events) {
const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id;
const cur = byD.get(d);
if (!cur || ev.created_at > cur.created_at) byD.set(d, ev);
}
return [...byD.values()]
.map((ev) => ({
id: ev.id,
d: ev.tags.find(([n]) => n === 'd')?.[1] ?? '',

View file

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

View file

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

View file

@ -27,9 +27,6 @@ export interface RiderCardConfig {
skills: string[];
sponsors: string;
ridesFor: string;
showSpots: boolean;
showZaps: boolean;
showEvents: boolean;
showCrews: boolean;
showLocal: boolean;
showRoles: boolean;
@ -39,6 +36,8 @@ export interface RiderCardConfig {
logo: string;
/** Whether the printable card has a back side. */
back: boolean;
/** Holographic sheen. */
holographic: boolean;
}
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
@ -47,21 +46,18 @@ export const DEFAULT_RIDER_CARD: RiderCardConfig = {
skills: [],
sponsors: '',
ridesFor: '',
showSpots: true,
showZaps: true,
showEvents: true,
showCrews: true,
showLocal: true,
showRoles: true,
accent: '',
logo: '',
back: true,
holographic: true,
};
/** Read a saved Rider Card event back into editable state. */
export function parseRiderCard(event: NostrEvent): RiderCardConfig {
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1] ?? '';
const hasShow = event.tags.some(([n]) => n === 'show');
const v2 = (Number(tag('v')) || 0) >= 2;
const show = new Set(event.tags.filter(([n]) => n === 'show').map(([, v]) => v));
return {
@ -70,11 +66,7 @@ export function parseRiderCard(event: NostrEvent): RiderCardConfig {
skills: event.tags.filter(([n]) => n === 'skill').map(([, v]) => v).filter(Boolean),
sponsors: tag('sponsor'),
ridesFor: tag('rides-for'),
// Spot/zap/event flags have always been written; facets arrived in v2, so an
// older card defaults them ON rather than silently hiding them.
showSpots: hasShow ? show.has('spots') : true,
showZaps: hasShow ? show.has('zaps') : true,
showEvents: hasShow ? show.has('events') : true,
// Facets arrived in v2; an older card defaults them ON rather than hiding them.
showCrews: v2 ? show.has('crews') : true,
showLocal: v2 ? show.has('local') : true,
showRoles: v2 ? show.has('roles') : true,
@ -82,6 +74,7 @@ export function parseRiderCard(event: NostrEvent): RiderCardConfig {
logo: tag('logo'),
// Back side defaults ON; an explicit ['back','0'] turns it off.
back: !(event.tags.some(([n, v]) => n === 'back' && v === '0')),
holographic: !(event.tags.some(([n, v]) => n === 'holo' && v === '0')),
};
}
@ -101,9 +94,7 @@ export function riderCardTags(c: RiderCardConfig): string[][] {
if (c.accent.trim()) tags.push(['accent', c.accent.trim()]);
if (c.logo.trim()) tags.push(['logo', c.logo.trim()]);
tags.push(['back', c.back ? '1' : '0']);
if (c.showSpots) tags.push(['show', 'spots']);
if (c.showZaps) tags.push(['show', 'zaps']);
if (c.showEvents) tags.push(['show', 'events']);
tags.push(['holo', c.holographic ? '1' : '0']);
if (c.showCrews) tags.push(['show', 'crews']);
if (c.showLocal) tags.push(['show', 'local']);
if (c.showRoles) tags.push(['show', 'roles']);

View file

@ -41,16 +41,9 @@ export interface CanvasCardData {
level?: string;
sponsors: string;
ridesFor: string;
showSpots: boolean;
showZaps: boolean;
showEvents: boolean;
showCrews: boolean;
showLocal: boolean;
showRoles: boolean;
spotsAdded: number;
zapsReceived: number;
eventsCreated: number;
memberYear: string;
receivedBadges: { skill: string }[];
verifyUrl?: string;
local?: string;

View file

@ -10,7 +10,6 @@ import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
import { useRiderStats } from '@/hooks/useRiderStats';
import { useRiderCard, useRiderBadges, useRiderRoles, useCardProfile } from '@/hooks/useRiderCard';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserSkaterProfile } from '@/hooks/useSkaters';
@ -19,7 +18,8 @@ import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { Button } from '@/components/ui/button';
import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard';
import { RIDER_CARD_KIND, RIDER_CARD_D, DEFAULT_RIDER_CARD, cardVerifyUrl, memberSince } from '@/lib/riderCard';
import { RIDER_CARD_KIND, RIDER_CARD_D, DEFAULT_RIDER_CARD, cardVerifyUrl } from '@/lib/riderCard';
import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
function decodePubkey(value: string): string | null {
if (/^[0-9a-f]{64}$/i.test(value)) return value.toLowerCase();
@ -40,13 +40,17 @@ export default function PublicCardPage() {
const cardProfile = useCardProfile(pubkey ?? undefined);
const metadata = cardProfile.data;
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : '');
const { data: stats } = useRiderStats(pubkey ?? undefined);
const { data: savedCard } = useRiderCard(pubkey ?? undefined);
const { data: badges = [] } = useRiderBadges(pubkey ?? undefined);
const { data: roles = [] } = useRiderRoles(pubkey ?? undefined);
const { memberships } = useUserCrews(pubkey ?? undefined);
const { data: skaterProfile } = useUserSkaterProfile(pubkey ?? undefined);
const derivedStyle = (skaterProfile?.styles ?? []).map((s) => SKATER_STYLES[s]?.label ?? s).join(' · ');
const derivedLevel = skaterProfile?.level
? (SKATER_LEVELS[skaterProfile.level]?.label ?? '')
: (skaterProfile?.customLevel ?? '');
const [side, setSide] = useState<'front' | 'back'>('front');
useSeoMeta({
@ -75,23 +79,20 @@ export default function PublicCardPage() {
const cardData: CardData = {
type: 'rider',
theme: config.theme as CardData['theme'],
holographic: true,
holographic: config.holographic ?? true,
name: displayName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
pubkey,
npub: nip19.npubEncode(pubkey),
style: config.style,
style: derivedStyle,
level: derivedLevel || undefined,
skills: config.skills,
sponsors: config.sponsors,
ridesFor: config.ridesFor,
showSpots: config.showSpots,
showZaps: config.showZaps,
showEvents: config.showEvents,
spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0,
eventsCreated: stats?.eventsCreated ?? 0,
memberYear: metadata?.created_at ? memberSince(metadata.created_at as number) : new Date().getFullYear().toString(),
showCrews: config.showCrews,
showLocal: config.showLocal,
showRoles: config.showRoles,
receivedBadges,
verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D),
accent: config.accent || undefined,

View file

@ -23,7 +23,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard';
import { NfcWriteButton } from '@/components/NfcWriteButton';
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard';
import { THEMES, SKILL_OPTIONS, fullNpub, type ThemeId } from '@/lib/riderCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -35,7 +35,6 @@ import { Badge } from '@/components/ui/badge';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useRiderStats } from '@/hooks/useRiderStats';
import { useRiderCard, useRiderRoles, useCardProfile, useIssuedPasses, useRevokePass, useReceivedPasses, type ReceivedPass } from '@/hooks/useRiderCard';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserSkaterProfile } from '@/hooks/useSkaters';
@ -167,7 +166,6 @@ export default function RiderCardPage() {
const { user } = useCurrentUser();
const cardProfile = useCardProfile(user?.pubkey);
const metadata = cardProfile.data;
const { data: stats } = useRiderStats(user?.pubkey);
const { data: savedCard } = useRiderCard(user?.pubkey);
const { data: allSpots } = useSkateSpots(200);
const { memberships } = useUserCrews(user?.pubkey);
@ -231,9 +229,6 @@ export default function RiderCardPage() {
// Rider fields
const [sponsors, setSponsors] = useState('');
const [ridesFor, setRidesFor] = useState('');
const [showSpots, setShowSpots] = useState(true);
const [showZaps, setShowZaps] = useState(true);
const [showEvents, setShowEvents] = useState(true);
const [showCrews, setShowCrews] = useState(true);
const [showLocal, setShowLocal] = useState(true);
const [showRoles, setShowRoles] = useState(true);
@ -296,15 +291,13 @@ export default function RiderCardPage() {
setTheme(c.theme as ThemeId);
setSponsors(c.sponsors);
setRidesFor(c.ridesFor);
setShowSpots(c.showSpots);
setShowZaps(c.showZaps);
setShowEvents(c.showEvents);
setShowCrews(c.showCrews);
setShowLocal(c.showLocal);
setShowRoles(c.showRoles);
setAccent(c.accent);
setLogo(c.logo);
setBack(c.back);
setHolographic(c.holographic);
}, [savedCard]);
// Unsaved-changes tracking for the personal card (manual save — an
@ -314,11 +307,10 @@ export default function RiderCardPage() {
if (!c) return true;
return (
c.theme !== theme || c.sponsors !== sponsors || c.ridesFor !== ridesFor ||
c.showSpots !== showSpots || c.showZaps !== showZaps || c.showEvents !== showEvents ||
c.showCrews !== showCrews || c.showLocal !== showLocal || c.showRoles !== showRoles ||
c.accent !== accent || c.logo !== logo || c.back !== back
c.accent !== accent || c.logo !== logo || c.back !== back || c.holographic !== holographic
);
}, [savedCard, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back]);
}, [savedCard, theme, sponsors, ridesFor, showCrews, showLocal, showRoles, accent, logo, back, holographic]);
const recipientPubkey = (() => {
if (!recipientNpub.trim()) return user?.pubkey ?? '';
@ -351,17 +343,10 @@ export default function RiderCardPage() {
logo: logo || undefined,
side: cardSide,
sponsors, ridesFor,
showSpots, showZaps, showEvents,
crews: crewCrests,
local: local || undefined,
roles,
showCrews, showLocal, showRoles,
spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0,
eventsCreated: stats?.eventsCreated ?? 0,
memberYear: metadata?.created_at
? memberSince(metadata.created_at as number)
: new Date().getFullYear().toString(),
receivedBadges,
verifyUrl: cardType === 'rider'
? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : undefined)
@ -401,8 +386,8 @@ export default function RiderCardPage() {
}),
[
cardType, theme, holographic, displayName, recipientName, metadata,
cardPubkey, derivedStyle, derivedLevel, sponsors, ridesFor, showSpots, showZaps,
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
cardPubkey, derivedStyle, derivedLevel, sponsors, ridesFor,
receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount,
accent, logo, cardSide,
],
@ -417,8 +402,6 @@ export default function RiderCardPage() {
pubkey: selectedPass.event.pubkey,
npub: '',
style: '', skills: [], sponsors: '', ridesFor: '',
showSpots: true, showZaps: true, showEvents: true,
spotsAdded: 0, zapsReceived: 0, eventsCreated: 0, memberYear: '',
receivedBadges: [],
verifyUrl: cardVerifyUrl(PARK_ACCESS_CARD_KIND, selectedPass.event.pubkey, selectedPass.d),
issuerName: selectedPass.issuer,
@ -433,9 +416,9 @@ export default function RiderCardPage() {
try {
const dataUrl = await drawCardToCanvas({
type: 'park', themeId: passCardData.theme, name: passCardData.name, pubkey: passCardData.pubkey, npub: '',
style: '', sponsors: '', ridesFor: '', showSpots: true, showZaps: true, showEvents: true,
style: '', sponsors: '', ridesFor: '',
showCrews: true, showLocal: true, showRoles: true,
spotsAdded: 0, zapsReceived: 0, eventsCreated: 0, memberYear: '', receivedBadges: [],
receivedBadges: [],
verifyUrl: passCardData.verifyUrl, holographic: true,
issuerName: passCardData.issuerName, recipientName: passCardData.recipientName,
accessLevel: passCardData.accessLevel, validThru: passCardData.validThru,
@ -467,16 +450,9 @@ export default function RiderCardPage() {
level: cardData.level,
sponsors: cardData.sponsors,
ridesFor: cardData.ridesFor,
showSpots: cardData.showSpots,
showZaps: cardData.showZaps,
showEvents: cardData.showEvents,
showCrews: cardData.showCrews ?? true,
showLocal: cardData.showLocal ?? true,
showRoles: cardData.showRoles ?? true,
spotsAdded: cardData.spotsAdded,
zapsReceived: cardData.zapsReceived,
eventsCreated: cardData.eventsCreated,
memberYear: cardData.memberYear,
receivedBadges: cardData.receivedBadges,
verifyUrl: cardData.verifyUrl,
local: cardData.local,
@ -497,7 +473,7 @@ export default function RiderCardPage() {
const handleSaveCard = useCallback(() => {
if (!user) return;
const config: RiderCardConfig = { theme, style: '', skills: [], sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back };
const config: RiderCardConfig = { theme, style: '', skills: [], sponsors, ridesFor, showCrews, showLocal, showRoles, accent, logo, back, holographic };
publishEvent(
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) },
{
@ -505,7 +481,7 @@ export default function RiderCardPage() {
onError: (e) => toast({ title: t('cardUi.saveFailed'), description: String(e), variant: 'destructive' }),
},
);
}, [user, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back, publishEvent, toast, t]);
}, [user, theme, sponsors, ridesFor, showCrews, showLocal, showRoles, accent, logo, back, holographic, publishEvent, toast, t]);
const handlePublishEventPass = useCallback(() => {
if (!user || !activeEvent) return;
@ -571,11 +547,11 @@ export default function RiderCardPage() {
const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' });
if (navigator.share && navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ title: `${cardData.name}'s SkateSpots Card`, text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/${cardData.npub}`, files: [file] });
await navigator.share({ title: `${cardData.name}'s SkateSpots Card`, text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/#/card/${cardData.npub}`, files: [file] });
return;
} catch { /* fall through */ }
}
await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`);
await navigator.clipboard.writeText(`https://skate-spots.org/#/card/${cardData.npub}`);
toast({ title: t('labUi.rider.linkCopied') });
}, [renderCardToImage, cardData.name, cardData.npub, toast, t]);
@ -717,7 +693,7 @@ export default function RiderCardPage() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleDownload} disabled={rendering} className="gap-2">
<Download className="h-4 w-4" /> {t('labUi.rider.savePng')}
<Download className="h-4 w-4" /> {back ? t('cardUi.saveFrontPng') : t('labUi.rider.savePng')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePrint} disabled={rendering} className="gap-2">
<Printer className="h-4 w-4" /> {t('labUi.rider.print')}
@ -883,9 +859,6 @@ export default function RiderCardPage() {
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.showOnCard')}</p>
<div className="divide-y divide-white/5 rounded-xl border border-white/8">
{[
{ icon: '🛹', label: t('labUi.pyramid.spots'), value: String(stats?.spotsAdded ?? 0), state: showSpots, set: setShowSpots },
{ icon: '⚡', label: t('labUi.rider.zaps'), value: String(stats?.zapsReceived ?? 0), state: showZaps, set: setShowZaps },
{ icon: '📅', label: t('labUi.pyramid.events'), value: String(stats?.eventsCreated ?? 0), state: showEvents, set: setShowEvents },
{ icon: '🏕️', label: t('cardUi.facets.crews'), value: String(crewCrests.length), state: showCrews, set: setShowCrews },
{ icon: '📍', label: t('cardUi.facets.local'), value: local || '—', state: showLocal, set: setShowLocal },
{ icon: '🎓', label: t('cardUi.facets.roles'), value: String(roles.length), state: showRoles, set: setShowRoles },