From ec02e4fe71ec30781c5e067d8154d98e578b3497 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 14:43:43 +0700 Subject: [PATCH] =?UTF-8?q?fix(card):=20audit=20fixes=20=E2=80=94=20share?= =?UTF-8?q?=20link,=20dup=20passes,=20dead=20stats,=20public=20style,=20ho?= =?UTF-8?q?lo=20persist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B1: Share's clipboard fallback copied https://…/ (a path HashRouter ignores → homepage). Now /#/card/ (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. --- src/components/RiderCard.tsx | 23 ++++------------ src/hooks/useRiderCard.ts | 10 ++++++- src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/lib/riderCard.ts | 21 ++++---------- src/lib/riderCardCanvas.ts | 7 ----- src/pages/PublicCardPage.tsx | 25 +++++++++-------- src/pages/RiderCardPage.tsx | 53 +++++++++--------------------------- 8 files changed, 48 insertions(+), 93 deletions(-) diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx index 4bd71fa..1de3eb6 100644 --- a/src/components/RiderCard.tsx +++ b/src/components/RiderCard.tsx @@ -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, diff --git a/src/hooks/useRiderCard.ts b/src/hooks/useRiderCard.ts index 2baa5d9..74681f6 100644 --- a/src/hooks/useRiderCard.ts +++ b/src/hooks/useRiderCard.ts @@ -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(); + 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] ?? '', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b023179..b90fd80 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index a0f4934..7c86b47 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/riderCard.ts b/src/lib/riderCard.ts index dd48d26..b58b086 100644 --- a/src/lib/riderCard.ts +++ b/src/lib/riderCard.ts @@ -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']); diff --git a/src/lib/riderCardCanvas.ts b/src/lib/riderCardCanvas.ts index 0e1c365..9009c88 100644 --- a/src/lib/riderCardCanvas.ts +++ b/src/lib/riderCardCanvas.ts @@ -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; diff --git a/src/pages/PublicCardPage.tsx b/src/pages/PublicCardPage.tsx index 54e5f6b..a914a91 100644 --- a/src/pages/PublicCardPage.tsx +++ b/src/pages/PublicCardPage.tsx @@ -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, diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 38b4a78..9684728 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -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() { - {t('labUi.rider.savePng')} + {back ? t('cardUi.saveFrontPng') : t('labUi.rider.savePng')} {t('labUi.rider.print')} @@ -883,9 +859,6 @@ export default function RiderCardPage() {

{t('cardUi.studio.showOnCard')}

{[ - { 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 },