From dbb9ea98c01b265fd59198ee032e78373655709e Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 10:26:54 +0700 Subject: [PATCH] feat(card): make Rider Cards real & verifiable - Publish the Rider Card as an addressable event (kind 32271, d=rider-card) and hydrate the editor from it, so customization persists across devices. - New routes: /card/:npub (public read-only card) and /verify/:naddr (signature check + park venue binding). - Park Access Cards can bind to the issuer's own spot (a tag 30818:...) and the verify page confirms the issuer owns the venue. - QR on the card (+ canvas export) now encodes the verify URL; the real QR renders in the React card too (was a placeholder / broken profile path). - Extracted shared card visuals to src/components/RiderCard.tsx; helpers in src/lib/riderCard.ts; hooks useRiderCard/useRiderBadges. - Documented in NIP.md; i18n cardUi namespace (EN/ES); smoke test covers the new routes. --- AGENTS.md | 14 ++ NIP.md | 56 ++++- scripts/smoke-test.mjs | 3 + src/AppRouter.tsx | 4 + src/components/RiderCard.tsx | 291 ++++++++++++++++++++++++ src/hooks/useRiderCard.ts | 80 +++++++ src/i18n/en.ts | 42 ++++ src/i18n/es.ts | 42 ++++ src/lib/riderCard.ts | 142 ++++++++++++ src/pages/PublicCardPage.tsx | 128 +++++++++++ src/pages/RiderCardPage.tsx | 419 +++++++---------------------------- src/pages/VerifyCardPage.tsx | 198 +++++++++++++++++ 12 files changed, 1075 insertions(+), 344 deletions(-) create mode 100644 src/components/RiderCard.tsx create mode 100644 src/hooks/useRiderCard.ts create mode 100644 src/lib/riderCard.ts create mode 100644 src/pages/PublicCardPage.tsx create mode 100644 src/pages/VerifyCardPage.tsx diff --git a/AGENTS.md b/AGENTS.md index 7d3cfcc..ad12996 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,20 @@ session with **no other memory of this project**. So: (which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and `AddPhotoToSpot`, replacing the old one-file-at-a-time inputs. - `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates + - **Rider Cards (2026-10-02) — now REAL + verifiable.** `32271` **Rider Card** + (addressable, `d=rider-card`) — the skater signs their own card customization + (theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags + explicit). `35679` Park Access Card (venue-bound via an optional + `["a","30818::"]` tag). `1293` witnessed skill badge. Routes: + `/card` (editor) · **`/#/card/`** (public, read-only) · + **`/#/verify/`** (signature check + venue binding). Helpers in + `src/lib/riderCard.ts` (`parseRiderCard`/`riderCardTags`/`cardVerifyUrl`), + hooks `useRiderCard`/`useRiderBadges`, visuals in `src/components/RiderCard.tsx` + (ONE React renderer; the canvas export in `RiderCardPage` is the print one). + ⚠️ **A valid signature proves the SIGNER — not skill, and not access rights** + beyond what the venue issued; the card says so. A pass with no `a` tag verifies + as a signature but is shown **not venue-bound** (any key can mint one). The QR + now encodes the verify URL (was a profile path that HashRouter ignored). - **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in the shared **`ListingTradeActions`** (used by BOTH the card and the detail page diff --git a/NIP.md b/NIP.md index fb23ae2..aeb1d45 100644 --- a/NIP.md +++ b/NIP.md @@ -1497,11 +1497,19 @@ Issued by a private skatepark owner to a specific rider, granting access rights. ["access-level", ""], ["theme", ""], ["valid-thru", ""], + ["a", "30818::"], ["alt", "Skatepark Access Card"] ] } ``` +**Venue binding (`a`, optional but recommended):** when present, it points at the +park's own spot (kind 30818). The `/verify` page fetches that spot and confirms +its **author pubkey equals the pass's signer** — i.e. the pass was issued by the +key that owns the venue. A pass with no `a` tag still verifies as a signature, +but is shown as **not bound to a venue** (any key can mint one), so a park that +cares about authenticity should always set it. + ### Card Themes | Value | Description | @@ -1513,31 +1521,59 @@ Issued by a private skatepark owner to a specific rider, granting access rights. | `midnight` | Deep Blue | | `sunset` | Orange & Pink | -### Rider License (client-side only) +### Rider Card (kind 32271, addressable) -The personal Rider License is generated client-side from the user's kind 0 profile metadata and on-chain stats. It is not a separate Nostr event — it renders from existing data: +The personal **Rider License** is published as an addressable event (kind 32271, +`d = "rider-card"`) signed by the skater themselves. It persists across devices +and can be linked to and verified. Name, avatar and NIP-05 still come from the +author's kind 0; on-chain stats are derived live: -- Name, avatar, NIP-05: from kind 0 - Spots added: count of kind 30818 authored by the user - Zaps received: count of kind 9735 targeting the user - Events created: count of kind 31923 authored by the user -- Park cards held: count of kind 35679 with `#p` = user - Witnessed skill badges: count of kind 1293 with `#p` = user -The Rider License includes two categories of skill badges: -- **Self-claimed** — selected by the rider themselves, shown in theme color -- **Witnessed** — awarded by others (kind 1293), shown in gold ⭐ +```json +{ + "kind": 32271, + "content": "", + "tags": [ + ["d", "rider-card"], + ["t", "rider-card"], + ["theme", ""], + ["v", "1"], + ["style", ""], + ["skill", ""], + ["sponsor", ""], + ["rides-for", ""], + ["show", "spots"], + ["show", "zaps"], + ["show", "events"], + ["alt", "SkateSpots Rider Card"] + ] +} +``` -The "Rides For" field displays the local shop the rider represents, distinct from sponsors (brands). +- `v:1` marks the `show` flags as explicit, so hiding a stat persists (an event + with no `v`/`show` tags defaults all three to visible). +- The card shows **self-claimed** skills (`skill` tags) in the theme colour and + **witnessed** badges (kind 1293) in gold ⭐. ### Verification -The QR code on printed Park Access cards encodes the Nostr address (`naddr`) of the issuing event. Verifiers scan the QR, fetch the event from any relay, and confirm the issuer's signature. +Both card types are verifiable. The QR on a printed card encodes the `naddr` of +its event, which opens the in-app verify page: ``` -URL format: https://skate-spots.org/verify/ +URL format: https://skate-spots.org/#/verify/ +Public card: https://skate-spots.org/#/card/ ``` +`/verify` fetches the event, runs `verifyEvent()` on the signature, and for a +park pass also checks the venue binding (above). It is deliberately honest: a +valid signature proves **who signed** the card — never skill, and never access +rights beyond what the venue itself issued. + ## Skill Badge Awards (kind 1293) Anyone who witnesses a rider performing a trick can award them a cryptographically signed skill badge. This creates an on-chain proof of skill — unfakeable because the witness's Nostr key signs the event. diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 4c677d3..6b3df11 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -145,6 +145,9 @@ const ROUTES = [ '/mints', '/app-shop', '/review', + '/card', + '/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx', + '/verify/notarealnaddr', '/messages/group/abc123', ]; const problems = []; diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 95fa1bf..054ff74 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -66,6 +66,8 @@ const LicensePage = lazyWithRetry(() => import("./pages/LicensePage")); const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage")); const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage")); const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage")); +const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage")); +const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage")); const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage")); const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage")); const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage")); @@ -168,6 +170,8 @@ export function AppRouter() { } /> {/* Info pages */} } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/RiderCard.tsx b/src/components/RiderCard.tsx new file mode 100644 index 0000000..93266d0 --- /dev/null +++ b/src/components/RiderCard.tsx @@ -0,0 +1,291 @@ +/** + * RiderCard — the visual card used by both the `/card` editor and the public + * `/card/:npub` view. Extracted so there is ONE React rendering of the card + * (the canvas export in `RiderCardPage` remains the print renderer). + */ +import { useEffect, useState } from 'react'; +import { SKILL_OPTIONS, shortNpub, type ThemeId } from '@/lib/riderCard'; +import { useTranslation } from '@/contexts/language'; +import { cn } from '@/lib/utils'; + +export interface BadgeAward { + witnessName: string; + witnessPubkey: string; + skill: string; + spotName?: string; + timestamp: number; +} + +export interface CardData { + type: 'rider' | 'park'; + theme: ThemeId; + holographic: boolean; + // Rider + name: string; + nip05?: string; + avatar?: string; + pubkey: string; + npub: string; + style: string; + skills: string[]; + sponsors: string; + ridesFor: string; + showSpots: boolean; + showZaps: boolean; + showEvents: boolean; + spotsAdded: number; + zapsReceived: number; + eventsCreated: number; + memberYear: string; + receivedBadges: BadgeAward[]; + /** When set, the printed QR opens the verify page for this card. */ + verifyUrl?: string; + // Park + issuerName: string; + recipientName: string; + accessLevel: string; + validThru: string; +} + +/** Real QR (lazy-imported so canvas API access stays out of module load). */ +function CardQr({ url }: { url?: string }) { + const [src, setSrc] = useState(null); + + useEffect(() => { + let cancelled = false; + if (!url) { setSrc(null); return; } + (async () => { + try { + const QRCode = (await import('qrcode')).default; + const dataUrl = await QRCode.toDataURL(url, { + width: 88, + margin: 0, + errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#ffffff' }, + }); + if (!cancelled) setSrc(dataUrl); + } catch { + if (!cancelled) setSrc(null); + } + })(); + return () => { cancelled = true; }; + }, [url]); + + return ( +
+ {src + ? QR + :
} +
+ ); +} + +// ─── Card visual: Rider front ───────────────────────────────────────────────── +// Layout is STRICTLY pixel-fixed to match the 340×214px card container. +function RiderFront({ d }: { d: CardData }) { + const { t } = useTranslation(); + const witnessedBadges = d.receivedBadges.slice(0, 2); + + return ( +
+ + {/* Row 1: header */} +
+
+
+ 🛹 SkateSpots +
+
{t('labUi.rider.previewLicense')}
+
+
+
npub
+
+ {shortNpub(d.pubkey)} +
+
{t('labUi.rider.previewSince', { year: d.memberYear })}
+
+
+ + {/* Row 2: main body */} +
+ + {/* Avatar */} +
+
+ {d.avatar ? ( + {d.name} + ) : ( +
🛹
+ )} +
+
+ {d.showSpots && ( +
+ {t('labUi.pyramid.spots')} + {d.spotsAdded} +
+ )} + {d.showZaps && ( +
+ ⚡ + {d.zapsReceived} +
+ )} + {d.showEvents && ( +
+ {t('labUi.rider.previewEvts')} + {d.eventsCreated} +
+ )} +
+
+ + {/* Identity block */} +
+
{d.name}
+ {d.nip05 && ( +
@{d.nip05.split('@')[0]}
+ )} + {d.style && ( + + {d.style} + + )} + {d.ridesFor && ( +
🏪 {d.ridesFor}
+ )} + {d.sponsors && ( +
🤝 {d.sponsors}
+ )} + {/* Skills — self-claimed then witnessed (gold) */} +
+ {d.skills.slice(0, 4).map(sk => { + const s = SKILL_OPTIONS.find(o => o.id === sk); + return s ? ( + + {s.icon} {s.label} + + ) : null; + })} + {witnessedBadges.map((b, i) => { + const s = SKILL_OPTIONS.find(o => o.id === b.skill); + return s ? ( + + ⭐ {s.label} + + ) : null; + })} +
+
+ + {/* QR */} +
+ +
+ {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} +
+
+
+ + {/* Bottom bar */} +
+ + skate-spots.org · Powered by Nostr + + + {shortNpub(d.pubkey)} + +
+
+ ); +} + +// ─── Card visual: Park Access front ────────────────────────────────────────── +function ParkFront({ d }: { d: CardData }) { + const { t } = useTranslation(); + + return ( +
+ +
+
+
+ 🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')} +
+
{t('labUi.rider.previewAccessPass')}
+
+
+ {d.accessLevel || 'Full Access'} +
+
+ +
+ +
+
+ {d.avatar ? ( + {d.recipientName} + ) : ( +
🛹
+ )} +
+
+ +
+
{d.recipientName || t('labUi.rider.previewMemberName')}
+
{shortNpub(d.pubkey)}
+
{t('labUi.rider.previewIssuedBy')}
+ {d.validThru && ( +
+ {t('labUi.rider.previewValidThru')} + {d.validThru} +
+ )} +
+ +
+ +
+ {d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')} +
+
+
+ +
+ + skate-spots.org · Powered by Nostr + +
+ {t('labUi.rider.previewNostrVerified')} +
+
+
+ ); +} + +export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { + const themeClass = `card-theme-${d.theme}`; + const holoClass = d.holographic ? 'rider-card-holo' : ''; + + return ( +
+
+ {d.type === 'rider' && } + {d.type === 'park' && } +
+ ); +} diff --git a/src/hooks/useRiderCard.ts b/src/hooks/useRiderCard.ts new file mode 100644 index 0000000..916f7c9 --- /dev/null +++ b/src/hooks/useRiderCard.ts @@ -0,0 +1,80 @@ +/** + * useRiderCard — fetch a skater's published Rider Card + witnessed skill badges. + * + * The Rider Card is an addressable event (kind 32271, d="rider-card") signed by + * the skater themselves, so it persists across devices and can be verified. + */ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { + RIDER_CARD_KIND, + RIDER_CARD_D, + SKILL_BADGE_KIND, + parseRiderCard, + type RiderCardConfig, +} from '@/lib/riderCard'; + +export interface RiderCardResult { + config: RiderCardConfig | null; + event: NostrEvent | null; +} + +export function useRiderCard(pubkey: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['rider-card', pubkey], + enabled: !!pubkey, + staleTime: 60_000, + queryFn: async (c) => { + if (!pubkey) return { config: null, event: null }; + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); + const events = await nostr.query( + [{ kinds: [RIDER_CARD_KIND], authors: [pubkey], '#d': [RIDER_CARD_D], limit: 1 }], + { signal }, + ); + const event = events[0] ?? null; + return { config: event ? parseRiderCard(event) : null, event }; + }, + }); +} + +export interface RiderBadge { + skill: string; + witnessPubkey: string; + spotName?: string; + timestamp: number; +} + +/** Skill badges witnessed for a rider (kind 1293, `#p` = rider). */ +export function useRiderBadges(pubkey: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['rider-badges', pubkey], + enabled: !!pubkey, + staleTime: 60_000, + queryFn: async (c) => { + if (!pubkey) return []; + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); + const events = await nostr.query( + [{ kinds: [SKILL_BADGE_KIND], '#p': [pubkey], limit: 100 }], + { signal }, + ); + return events + .filter((e) => e.tags.some(([k]) => k === 'skill')) + .flatMap((e) => { + const spot = e.tags.find(([k]) => k === 'spot')?.[1]; + return e.tags + .filter(([k]) => k === 'skill') + .map(([, skill]) => ({ + skill, + witnessPubkey: e.pubkey, + spotName: spot, + timestamp: e.created_at, + })); + }); + }, + }); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f9de573..77d2012 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6290,6 +6290,48 @@ walletUi: { deleteDescGeneric: 'This item will be removed from your stash. This can’t be undone.', }, }, + cardUi: { + save: 'Save card to Nostr', + saved: 'Card saved', + savedDesc: 'Your card is now signed and verifiable.', + saveFailed: 'Could not save the card', + notPublished: 'Not published', + venue: 'Linked venue (optional)', + venueNone: 'No venue', + venueHint: 'Pick one of your own spots to bind this pass to a venue. A verifier will confirm you own it.', + verify: { + checking: 'Checking the card…', + notFound: 'Card not found', + notFoundDesc: 'No matching event was found on the relays. It may have been deleted, or the link is wrong.', + invalid: 'Signature invalid', + invalidDesc: 'This event exists but its signature does not verify. Do not trust it.', + authentic: 'Authentic', + authenticDesc: 'The issuer’s signature is valid.', + kind: 'Type', + kindPark: 'Park Access Card', + kindRider: 'Rider Card', + issuedBy: 'Issued by', + accessLevel: 'Access level', + validThru: 'Valid thru', + selfIssued: 'Status', + selfIssuedDesc: 'Self-issued identity card', + venue: 'Venue', + venueVerified: 'Verified — this venue’s owner key matches the issuer.', + venueMismatch: 'This pass names a venue that is not owned by the issuer’s key.', + venueNone: 'Not bound to a venue.', + viewCard: 'View the card', + disclaimer: 'A valid signature proves who signed this card — it is not proof of skill or of access rights beyond what the venue issued.', + }, + public: { + invalid: 'That doesn’t look like a valid card link.', + createYourOwn: 'Create your own card', + editYourCard: 'Edit your card', + backToEditor: 'Back to the editor', + backHome: 'Back home', + notCustomised: 'This skater hasn’t customised their card yet — this is the default look.', + verifyNote: 'Scan the QR on the card to verify it.', + }, + }, }; export default en; diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 931345e..d9452b2 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6290,6 +6290,48 @@ walletUi: { deleteDescGeneric: 'Este artículo se quitará de tu stash. Esto no se puede deshacer.', }, }, + cardUi: { + save: 'Guardar carné en Nostr', + saved: 'Carné guardado', + savedDesc: 'Tu carné está firmado y se puede verificar.', + saveFailed: 'No se pudo guardar el carné', + notPublished: 'Sin publicar', + venue: 'Sede vinculada (opcional)', + venueNone: 'Sin sede', + venueHint: 'Elige uno de tus propios spots para vincular este pase a una sede. Un verificador confirmará que es tuya.', + verify: { + checking: 'Comprobando el carné…', + notFound: 'Carné no encontrado', + notFoundDesc: 'No se ha encontrado ningún evento en los relés. Puede que se haya eliminado o que el enlace sea incorrecto.', + invalid: 'Firma no válida', + invalidDesc: 'Este evento existe pero su firma no se verifica. No te fíes.', + authentic: 'Auténtico', + authenticDesc: 'La firma del emisor es válida.', + kind: 'Tipo', + kindPark: 'Pase de acceso a parque', + kindRider: 'Carné de rider', + issuedBy: 'Emitido por', + accessLevel: 'Nivel de acceso', + validThru: 'Válido hasta', + selfIssued: 'Estado', + selfIssuedDesc: 'Carné de identidad autoemitido', + venue: 'Sede', + venueVerified: 'Verificado — la clave del dueño de esta sede coincide con el emisor.', + venueMismatch: 'Este pase nombra una sede que no pertenece a la clave del emisor.', + venueNone: 'Sin vincular a ninguna sede.', + viewCard: 'Ver el carné', + disclaimer: 'Una firma válida demuestra quién firmó este carné — no es prueba de habilidad ni de derechos de acceso más allá de lo que emitió la sede.', + }, + public: { + invalid: 'Eso no parece un enlace de carné válido.', + createYourOwn: 'Crea tu propio carné', + editYourCard: 'Edita tu carné', + backToEditor: 'Volver al editor', + backHome: 'Volver al inicio', + notCustomised: 'Este skater aún no ha personalizado su carné — esta es la apariencia por defecto.', + verifyNote: 'Escanea el QR del carné para verificarlo.', + }, + }, }; export default es; diff --git a/src/lib/riderCard.ts b/src/lib/riderCard.ts new file mode 100644 index 0000000..077f03c --- /dev/null +++ b/src/lib/riderCard.ts @@ -0,0 +1,142 @@ +/** + * Rider Card / Park Access Card — shared constants + helpers. + * + * The personal Rider License used to be client-side only (see NIP.md → Rider + * Cards). It is now published as an **addressable** event so a skater's card + * customization persists across devices and can be linked to and verified. + * + * Kinds: + * 32271 — Rider Card (addressable, d = "rider-card"). The skater signs it. + * 35679 — Park Access Card (addressable). A venue/owner signs it for a rider. + * 1293 — Skill badge (regular). A witness signs it for a rider. + */ +import { nip19 } from 'nostr-tools'; +import type { NostrEvent } from '@nostrify/nostrify'; + +export const RIDER_CARD_KIND = 32271; +export const RIDER_CARD_D = 'rider-card'; +export const PARK_ACCESS_CARD_KIND = 35679; +export const SKILL_BADGE_KIND = 1293; + +/** Public site origin used for QR / verify links (the app is a HashRouter). */ +export const CARD_ORIGIN = 'https://skate-spots.org'; + +export interface RiderCardConfig { + theme: string; + style: string; + skills: string[]; + sponsors: string; + ridesFor: string; + showSpots: boolean; + showZaps: boolean; + showEvents: boolean; +} + +export const DEFAULT_RIDER_CARD: RiderCardConfig = { + theme: 'thug', + style: 'Street', + skills: [], + sponsors: '', + ridesFor: '', + showSpots: true, + showZaps: true, + showEvents: 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] ?? ''; + // `v` marks the "show" flags as explicitly set — older/absent events default + // all three to visible (so a card saved before a stat was ever hidden is fine). + const versioned = event.tags.some(([n]) => n === 'v'); + 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'), + showSpots: versioned ? show.has('spots') : true, + showZaps: versioned ? show.has('zaps') : true, + showEvents: versioned ? show.has('events') : true, + }; +} + +/** Build the tags for the addressable Rider Card event. */ +export function riderCardTags(c: RiderCardConfig): string[][] { + const tags: string[][] = [ + ['d', RIDER_CARD_D], + ['t', 'rider-card'], + ['alt', 'SkateSpots Rider Card'], + ['theme', c.theme], + ['v', '1'], + ]; + 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.showSpots) tags.push(['show', 'spots']); + if (c.showZaps) tags.push(['show', 'zaps']); + if (c.showEvents) tags.push(['show', 'events']); + return tags; +} + +/** naddr for an addressable card event. */ +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. */ +export function cardVerifyUrl(kind: number, pubkey: string, d: string): string { + return `${CARD_ORIGIN}/#/verify/${cardNaddr(kind, pubkey, d)}`; +} + +/** HashRouter URL for a public card page. */ +export function cardPublicUrl(pubkey: string): string { + return `${CARD_ORIGIN}/#/card/${nip19.npubEncode(pubkey)}`; +} + +// ─── View presets (shared by the editor + the public card) ──────────────────── +export const THEMES = [ + { id: 'thug', label: 'THUG', emoji: '🔴', desc: 'Black + Neon Red' }, + { id: 'lime', label: 'LIME', emoji: '🟢', desc: 'Black + Neon Lime' }, + { id: 'chrome', label: 'CHROME', emoji: '⚪', desc: 'Steel & Silver' }, + { id: 'wood', label: 'WOOD', emoji: '🟤', desc: 'Woodgrain Classic' }, + { id: 'midnight', label: 'MIDNIGHT', emoji: '🔵', desc: 'Deep Blue' }, + { id: 'sunset', label: 'SUNSET', emoji: '🟠', desc: 'Orange & Pink' }, +] as const; +export type ThemeId = typeof THEMES[number]['id']; + +export const SKILL_OPTIONS = [ + { id: 'kickflip', label: 'Kickflip', icon: '🛹' }, + { id: 'heelflip', label: 'Heelflip', icon: '👟' }, + { id: 'switch', label: 'Switch', icon: '🔄' }, + { id: 'nollie', label: 'Nollie', icon: '⬆️' }, + { id: 'fakie', label: 'Fakie', icon: '↩️' }, + { id: 'grind', label: 'Grinds', icon: '⚡' }, + { id: 'slide', label: 'Slides', icon: '➡️' }, + { id: 'manual', label: 'Manuals', icon: '🎯' }, + { id: 'vert', label: 'Vert', icon: '🏄' }, + { id: 'bowl', label: 'Bowl', icon: '🥣' }, + { id: 'street', label: 'Street', icon: '🏙️' }, + { id: 'park', label: 'Park', icon: '🌿' }, + { id: 'film', label: 'Filmer', icon: '🎥' }, + { id: 'diy', label: 'DIY Builder', icon: '🔨' }, +] as const; + +export const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const; + +export function shortNpub(pubkey: string) { + if (!pubkey) return '—'; + const npub = nip19.npubEncode(pubkey); + return `${npub.slice(0, 12)}…${npub.slice(-6)}`; +} + +export function fullNpub(pubkey: string) { + if (!pubkey) return ''; + return nip19.npubEncode(pubkey); +} + +export function memberSince(createdAt: number) { + return new Date(createdAt * 1000).getFullYear().toString(); +} diff --git a/src/pages/PublicCardPage.tsx b/src/pages/PublicCardPage.tsx new file mode 100644 index 0000000..ba4c4f0 --- /dev/null +++ b/src/pages/PublicCardPage.tsx @@ -0,0 +1,128 @@ +/** + * PublicCardPage — /card/:npub + * + * A read-only view of a skater's Rider Card, shareable and scannable. The QR on + * the card opens /verify so anyone can confirm the card is genuine. + */ +import { useParams, Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { useSeoMeta } from '@unhead/react'; +import { ArrowLeft, Sparkles } from 'lucide-react'; + +import { useAuthor } from '@/hooks/useAuthor'; +import { useRiderStats } from '@/hooks/useRiderStats'; +import { useRiderCard, useRiderBadges } from '@/hooks/useRiderCard'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useTranslation } from '@/contexts/language'; +import { genUserName } from '@/lib/genUserName'; +import { Button } from '@/components/ui/button'; +import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard'; +import { RIDER_CARD_KIND, RIDER_CARD_D, DEFAULT_RIDER_CARD, cardVerifyUrl, memberSince } from '@/lib/riderCard'; + +function decodePubkey(value: string): string | null { + if (/^[0-9a-f]{64}$/i.test(value)) return value.toLowerCase(); + try { + const d = nip19.decode(value); + return d.type === 'npub' ? d.data : null; + } catch { + return null; + } +} + +export default function PublicCardPage() { + const { t } = useTranslation(); + const { npub = '' } = useParams<{ npub: string }>(); + const pubkey = decodePubkey(npub); + const { user } = useCurrentUser(); + + const author = useAuthor(pubkey ?? ''); + const metadata = author.data?.metadata; + 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); + + useSeoMeta({ + title: `${displayName || 'Rider'} card — SkateSpots`, + description: `A SkateSpots Rider Card${displayName ? ` for ${displayName}` : ''}.`, + }); + + if (!pubkey) { + return ( +
+

{t('cardUi.public.invalid')}

+ +
+ ); + } + + const config = savedCard?.config ?? DEFAULT_RIDER_CARD; + const receivedBadges: BadgeAward[] = badges.map(b => ({ + skill: b.skill, + witnessName: genUserName(b.witnessPubkey), + witnessPubkey: b.witnessPubkey, + spotName: b.spotName, + timestamp: b.timestamp, + })); + + const cardData: CardData = { + type: 'rider', + theme: config.theme as CardData['theme'], + holographic: true, + name: displayName, + nip05: metadata?.nip05, + avatar: metadata?.picture, + pubkey, + npub: nip19.npubEncode(pubkey), + style: config.style, + 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(), + receivedBadges, + verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D), + issuerName: '', + recipientName: '', + accessLevel: '', + validThru: '', + }; + + const isMe = user?.pubkey === pubkey; + + return ( +
+
+ + {isMe ? t('cardUi.public.backToEditor') : t('cardUi.public.backHome')} + +
+ +
+ +
+ + {!savedCard?.config && ( +

{t('cardUi.public.notCustomised')}

+ )} + +
+ +

{t('cardUi.public.verifyNote')}

+
+
+ ); +} diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index b8d18bf..d4e0445 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -13,13 +13,15 @@ * Print-ready: credit-card size (85.6×54mm), @media print isolates card */ -import { useState, useRef, useCallback, useMemo } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; // QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API // access at module load time (crashes the preview environment) import { LoginArea } from '@/components/auth/LoginArea'; +import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard'; +import { THEMES, SKILL_OPTIONS, STYLES, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -31,6 +33,8 @@ import { Badge } from '@/components/ui/badge'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useRiderStats } from '@/hooks/useRiderStats'; +import { useRiderCard } from '@/hooks/useRiderCard'; +import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { useNostr } from '@/hooks/useNostr'; @@ -38,8 +42,10 @@ import { useQuery } from '@tanstack/react-query'; import { genUserName } from '@/lib/genUserName'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; +import { RIDER_CARD_KIND, RIDER_CARD_D, PARK_ACCESS_CARD_KIND, SKILL_BADGE_KIND, cardVerifyUrl, riderCardTags, type RiderCardConfig } from '@/lib/riderCard'; +import { SKATE_SPOT_KIND } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; -import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, Shield, ChevronUp, ChevronDown, User, Building2 } from 'lucide-react'; +import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2 } from 'lucide-react'; // ─── Card canvas renderer ───────────────────────────────────────────────────── // Draws the card entirely via Canvas 2D API — 100% reliable export, // no CSS capture, no scaling issues, no background-stripping. @@ -68,6 +74,8 @@ interface CanvasCardData { eventsCreated: number; memberYear: string; receivedBadges: BadgeAward[]; + /** When set, the printed QR opens the verify page for this card. */ + verifyUrl?: string; // Park issuerName: string; recipientName: string; @@ -185,7 +193,8 @@ async function drawCardToCanvas(data: CanvasCardData): Promise { ctx.fillRect(0, 0, CARD_W, 10); // ─── 4. QR code (top-right, generated first — data: URI safe) ──────────── - const qrUrl = `https://skate-spots.org/${data.npub}`; + // The QR opens the card's verify page when published; otherwise the profile. + const qrUrl = data.verifyUrl || `https://skate-spots.org/${data.npub}`; const qrSize = 168; const qrX = CARD_W - 32 - qrSize; const qrY = 28; @@ -501,102 +510,6 @@ async function drawCardToCanvas(data: CanvasCardData): Promise { return canvas.toDataURL('image/png'); } -// ─── Nostr kinds ────────────────────────────────────────────────────────────── -export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider -export const SKILL_BADGE_KIND = 1293; // Witness awards skill badge to rider - -// ─── Themes ─────────────────────────────────────────────────────────────────── -const THEMES = [ - { id: 'thug', label: 'THUG', emoji: '🔴', desc: 'Black + Neon Red' }, - { id: 'lime', label: 'LIME', emoji: '🟢', desc: 'Black + Neon Lime' }, - { id: 'chrome', label: 'CHROME', emoji: '⚪', desc: 'Steel & Silver' }, - { id: 'wood', label: 'WOOD', emoji: '🟤', desc: 'Woodgrain Classic' }, - { id: 'midnight', label: 'MIDNIGHT', emoji: '🔵', desc: 'Deep Blue' }, - { id: 'sunset', label: 'SUNSET', emoji: '🟠', desc: 'Orange & Pink' }, -] as const; -type ThemeId = typeof THEMES[number]['id']; - -// ─── Skill badges ───────────────────────────────────────────────────────────── -const SKILL_OPTIONS = [ - { id: 'kickflip', label: 'Kickflip', icon: '🛹' }, - { id: 'heelflip', label: 'Heelflip', icon: '👟' }, - { id: 'switch', label: 'Switch', icon: '🔄' }, - { id: 'nollie', label: 'Nollie', icon: '⬆️' }, - { id: 'fakie', label: 'Fakie', icon: '↩️' }, - { id: 'grind', label: 'Grinds', icon: '⚡' }, - { id: 'slide', label: 'Slides', icon: '➡️' }, - { id: 'manual', label: 'Manuals', icon: '🎯' }, - { id: 'vert', label: 'Vert', icon: '🏄' }, - { id: 'bowl', label: 'Bowl', icon: '🥣' }, - { id: 'street', label: 'Street', icon: '🏙️' }, - { id: 'park', label: 'Park', icon: '🌿' }, - { id: 'film', label: 'Filmer', icon: '🎥' }, - { id: 'diy', label: 'DIY Builder', icon: '🔨' }, -] as const; - -const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const; - -// ─── Helpers ────────────────────────────────────────────────────────────────── -function shortNpub(pubkey: string) { - if (!pubkey) return '—'; - const npub = nip19.npubEncode(pubkey); - return `${npub.slice(0, 12)}…${npub.slice(-6)}`; -} - -function fullNpub(pubkey: string) { - if (!pubkey) return ''; - return nip19.npubEncode(pubkey); -} - -function memberSince(createdAt: number) { - return new Date(createdAt * 1000).getFullYear().toString(); -} - -// Verification URL — scanning this on a phone opens the profile on skate-spots.org -function _verifyUrl(pubkey: string) { - if (!pubkey) return 'https://skate-spots.org'; - return `https://skate-spots.org/${nip19.npubEncode(pubkey)}`; -} - -// ─── Badge award event type ─────────────────────────────────────────────────── -interface BadgeAward { - witnessName: string; - witnessPubkey: string; - skill: string; - spotName?: string; - timestamp: number; -} - -// ─── Card data shape ────────────────────────────────────────────────────────── -interface CardData { - type: 'rider' | 'park'; - theme: ThemeId; - holographic: boolean; - // Rider - name: string; - nip05?: string; - avatar?: string; - pubkey: string; - npub: string; - style: string; - skills: string[]; - sponsors: string; - ridesFor: string; - showSpots: boolean; - showZaps: boolean; - showEvents: boolean; - spotsAdded: number; - zapsReceived: number; - eventsCreated: number; - memberYear: string; - receivedBadges: BadgeAward[]; - // Park - issuerName: string; - recipientName: string; - accessLevel: string; - validThru: string; -} - // ─── Hook: fetch skill badges received by a pubkey ──────────────────────────── function useReceivedBadges(pubkey: string) { const { nostr } = useNostr(); @@ -624,237 +537,6 @@ function useWitnessName(pubkey: string) { return author.data?.metadata?.name ?? genUserName(pubkey); } -// ─── Card visual: Rider front ───────────────────────────────────────────────── -// Layout is STRICTLY pixel-fixed to match the 340×214px card container. -// No flex-1 / flex-grow — every row has a fixed pixel height. -function RiderFront({ d }: { d: CardData }) { - const { t } = useTranslation(); - const witnessedBadges = d.receivedBadges.slice(0, 2); - - return ( - // absolute inset — fills the card exactly -
- - {/* ── Row 1: header (26px) ── */} -
-
-
- 🛹 SkateSpots -
-
{t('labUi.rider.previewLicense')}
-
-
-
npub
-
- {shortNpub(d.pubkey)} -
-
{t('labUi.rider.previewSince', { year: d.memberYear })}
-
-
- - {/* ── Row 2: main body (118px) ── */} -
- - {/* Avatar */} -
-
- {d.avatar ? ( - {d.name} - ) : ( -
🛹
- )} -
- {/* Stats under avatar */} -
- {d.showSpots && ( -
- {t('labUi.pyramid.spots')} - {d.spotsAdded} -
- )} - {d.showZaps && ( -
- ⚡ - {d.zapsReceived} -
- )} - {d.showEvents && ( -
- {t('labUi.rider.previewEvts')} - {d.eventsCreated} -
- )} -
-
- - {/* Identity block */} -
-
{d.name}
- {d.nip05 && ( -
@{d.nip05.split('@')[0]}
- )} - {d.style && ( - - {d.style} - - )} - {d.ridesFor && ( -
🏪 {d.ridesFor}
- )} - {d.sponsors && ( -
🤝 {d.sponsors}
- )} - {/* Skills */} -
- {d.skills.slice(0, 4).map(sk => { - const s = SKILL_OPTIONS.find(o => o.id === sk); - return s ? ( - - {s.icon} {s.label} - - ) : null; - })} - {witnessedBadges.map((b, i) => { - const s = SKILL_OPTIONS.find(o => o.id === b.skill); - return s ? ( - - ⭐ {s.label} - - ) : null; - })} -
-
- - {/* QR placeholder — real QR renders in exported PNG */} -
-
-
- {Array.from({ length: 25 }).map((_, i) => ( -
- ))} -
-
-
{t('labUi.rider.previewScan')}
-
-
- - {/* ── Bottom bar: npub + website (absolute at bottom) ── */} -
- - skate-spots.org · Powered by Nostr - - - {shortNpub(d.pubkey)} - -
-
- ); -} - -// ─── Card visual: Park Access front ────────────────────────────────────────── -function ParkFront({ d }: { d: CardData }) { - const { t } = useTranslation(); - - return ( -
- - {/* Row 1: header */} -
-
-
- 🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')} -
-
{t('labUi.rider.previewAccessPass')}
-
-
- {d.accessLevel || 'Full Access'} -
-
- - {/* Row 2: main body */} -
- - {/* Avatar */} -
-
- {d.avatar ? ( - {d.recipientName} - ) : ( -
🛹
- )} -
-
- - {/* Identity */} -
-
{d.recipientName || t('labUi.rider.previewMemberName')}
-
{shortNpub(d.pubkey)}
-
{t('labUi.rider.previewIssuedBy')}
- {d.validThru && ( -
- {t('labUi.rider.previewValidThru')} - {d.validThru} -
- )} -
- - {/* QR placeholder */} -
-
-
- {Array.from({ length: 25 }).map((_, i) => ( -
- ))} -
-
-
{t('labUi.rider.previewScan')}
-
-
- - {/* Bottom bar */} -
- - skate-spots.org · Powered by Nostr - -
- - {t('labUi.rider.previewNostrVerified')} -
-
-
- ); -} - -// ─── Main card wrapper ──────────────────────────────────────────────────────── -function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject }) { - const themeClass = `card-theme-${d.theme}`; - const holoClass = d.holographic ? 'rider-card-holo' : ''; - - return ( -
- {/* Top color stripe */} -
- - {d.type === 'rider' && } - {d.type === 'park' && } -
- ); -} - // ─── Received badges panel ──────────────────────────────────────────────────── function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) { const { t } = useTranslation(); @@ -941,6 +623,8 @@ export default function RiderCardPage() { const author = useAuthor(user?.pubkey ?? ''); const metadata = author.data?.metadata; const { data: stats } = useRiderStats(user?.pubkey); + const { data: savedCard } = useRiderCard(user?.pubkey); + const { data: allSpots } = useSkateSpots(200); const { mutate: publishEvent } = useNostrPublish(); const { toast } = useToast(); const printRef = useRef(null); @@ -988,6 +672,32 @@ export default function RiderCardPage() { const [copied, setCopied] = useState(false); const [rendering, setRendering] = useState(false); + // Venue binding: a park pass can name one of the issuer's OWN spots so a + // verifier can confirm the issuer really owns the venue (see /verify). + const [venueId, setVenueId] = useState(''); + const [savedParkD, setSavedParkD] = useState(''); + const myVenues = useMemo( + () => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey), + [allSpots, user?.pubkey], + ); + const venueSpot = myVenues.find((s) => s.id === venueId); + + // Hydrate the editor from the saved Rider Card the first time it loads. + const hydratedRef = useRef(false); + useEffect(() => { + if (hydratedRef.current || !savedCard?.config) return; + hydratedRef.current = true; + const c = savedCard.config; + setTheme(c.theme as ThemeId); + setStyle(c.style); + setSkills(c.skills); + setSponsors(c.sponsors); + setRidesFor(c.ridesFor); + setShowSpots(c.showSpots); + setShowZaps(c.showZaps); + setShowEvents(c.showEvents); + }, [savedCard]); + const recipientPubkey = (() => { if (!recipientNpub.trim()) return user?.pubkey ?? ''; try { @@ -1014,12 +724,15 @@ export default function RiderCardPage() { ? 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) + : (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined), issuerName, recipientName, accessLevel, validThru, }), [ cardType, theme, holographic, displayName, recipientName, metadata, cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps, - showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, + showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey, ], ); @@ -1042,6 +755,18 @@ export default function RiderCardPage() { finally { setRendering(false); } }, [cardData]); + const handleSaveCard = useCallback(() => { + if (!user) return; + const config: RiderCardConfig = { theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents }; + publishEvent( + { kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) }, + { + onSuccess: () => toast({ title: t('cardUi.saved'), description: t('cardUi.savedDesc') }), + onError: (e) => toast({ title: t('cardUi.saveFailed'), description: String(e), variant: 'destructive' }), + }, + ); + }, [user, theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, publishEvent, toast, t]); + const handlePrint = useCallback(async () => { const dataUrl = await renderCardToImage(); if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; } @@ -1086,17 +811,20 @@ export default function RiderCardPage() { const handlePublishParkCard = useCallback(() => { if (!user || !recipientPubkey) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; } + const d = `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`; const tags: string[][] = [ - ['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`], + ['d', d], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'], ['issuer', issuerName], ['access-level', accessLevel], ['theme', theme], ]; if (validThru) tags.push(['valid-thru', validThru]); + // Bind the pass to the venue the issuer owns, so /verify can confirm it. + if (venueSpot) tags.push(['a', `${SKATE_SPOT_KIND}:${venueSpot.pubkey}:${venueSpot.identifier}`]); publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { - onSuccess: () => toast({ title: t('labUi.rider.parkPublished') }), + onSuccess: () => { setSavedParkD(d); toast({ title: t('labUi.rider.parkPublished') }); }, onError: (e) => toast({ title: t('labUi.rider.publishFailed'), description: String(e), variant: 'destructive' }), }); - }, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast, t]); + }, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, venueSpot, publishEvent, toast, t]); const toggleSkill = (id: string) => setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]); @@ -1222,6 +950,17 @@ export default function RiderCardPage() { {copied ? t('common.copied') : t('labUi.rider.copyId')}
+ + {/* Save the card to Nostr so it persists & verifies */} + {cardType === 'rider' && ( + + )}
{/* ── Card type toggle ── */} @@ -1434,6 +1173,18 @@ export default function RiderCardPage() {
+
+ + +

{t('cardUi.venueHint')}

+
+