diff --git a/AGENTS.md b/AGENTS.md index fdce079..c79bd43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,12 @@ session with **no other memory of this project**. So: watermark**. Card pages resolve the profile via **`useCardProfile`** (tolerant JSON over `querySkateContent`, so it can't drop a real profile the way `useAuthor` could); `DEFAULT_RIDER_CARD.style` is now `''` (no fake "Street"). + **Issuance logic (2026-10-02):** card types are **gated by what you can + actually issue/derive** — Park only if you own a venue (a spot you authored), + Crew only if you're in a crew, Event only if you organised/RSVP'd. A Park pass + **requires** a venue binding (`a` tag) and is only issuable by that venue's + owner key; `/verify` shows the **holder** + venue match. The studio shows a + **"Finish your card"** checklist (photo/name/styles/crew). - **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 b7ebf83..1a0f332 100644 --- a/NIP.md +++ b/NIP.md @@ -1503,12 +1503,12 @@ Issued by a private skatepark owner to a specific rider, granting access rights. } ``` -**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. +**Venue binding (`a`) — REQUIRED for new passes:** it points at the park's own +spot (kind 30818). A pass is only issuable by the **key that owns that venue**, +and the `/verify` page fetches the spot and confirms its **author pubkey equals +the pass's signer**. The `/card` studio only offers the Park type when you own a +venue, and won't publish without one. (Legacy passes with no `a` still verify as +a signature but are shown as **not venue-bound**.) ### Card Themes diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d08cc4b..8266690 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6331,6 +6331,10 @@ walletUi: { 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.', + venuePick: 'Choose your venue', + parkNoVenue: 'A Park pass can only be issued by the venue’s owner — map a spot you own first.', + parkAddVenue: 'Add a spot', + parkNeedsVenue: 'Pick a venue you own', facets: { title: 'Show identity facets', crews: 'Crews', @@ -6393,6 +6397,7 @@ walletUi: { checkName: 'Display name', checkProfile: 'Styles & level', checkCrew: 'Join a crew', + moreTypes: 'More card types unlock as you map spots you own, join crews, or RSVP to events.', accent: 'Accent colour', accentHint: 'Overrides the theme accent', accentClear: 'Use theme colour', @@ -6423,6 +6428,7 @@ walletUi: { kindEvent: 'Event pass', expired: 'Expired', valid: 'Valid', + holder: 'Holder', issuedBy: 'Issued by', accessLevel: 'Access level', validThru: 'Valid thru', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index b1699a6..514de0d 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6331,6 +6331,10 @@ walletUi: { 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.', + venuePick: 'Elige tu sede', + parkNoVenue: 'Un pase de parque solo puede emitirlo el dueño de la sede — mapea primero un spot tuyo.', + parkAddVenue: 'Añadir un spot', + parkNeedsVenue: 'Elige una sede tuya', facets: { title: 'Mostrar facetas de identidad', crews: 'Cuadrillas', @@ -6393,6 +6397,7 @@ walletUi: { checkName: 'Nombre visible', checkProfile: 'Estilos y nivel', checkCrew: 'Únete a una cuadrilla', + moreTypes: 'Más tipos de carné se desbloquean al mapear spots tuyos, unirte a cuadrillas o confirmar asistencia a eventos.', accent: 'Color de acento', accentHint: 'Sustituye el acento del tema', accentClear: 'Usar color del tema', @@ -6423,6 +6428,7 @@ walletUi: { kindEvent: 'Pase de evento', expired: 'Caducado', valid: 'Válido', + holder: 'Titular', issuedBy: 'Emitido por', accessLevel: 'Nivel de acceso', validThru: 'Válido hasta', diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 43a96ac..1b93b6f 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -503,21 +503,24 @@ export default function RiderCardPage() { }, [renderCardToImage, cardData.name, cardData.npub, toast, t]); const handlePublishParkCard = useCallback(() => { - if (!user || !recipientPubkey) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; } + if (!user) return; + if (!recipientPubkey || !recipientNpub.trim()) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; } + // ISSUANCE RULE: only the venue's owner key may issue a Park pass, and it + // must be bound to that venue — otherwise the pass proves nothing. + if (!venueSpot) { toast({ title: t('cardUi.parkNeedsVenue'), variant: 'destructive' }); return; } const d = `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`; const tags: string[][] = [ ['d', d], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'], - ['issuer', issuerName], ['access-level', accessLevel], ['theme', theme], + ['issuer', issuerName.trim() || venueSpot.title], ['access-level', accessLevel], ['theme', theme], + ['a', `${SKATE_SPOT_KIND}:${venueSpot.pubkey}:${venueSpot.identifier}`], ]; 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: () => { 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, venueSpot, publishEvent, toast, t]); + }, [user, recipientPubkey, recipientNpub, issuerName, accessLevel, validThru, theme, venueSpot, publishEvent, toast, t]); // ── Logged out ──────────────────────────────────────────────────────────── if (!user) { @@ -694,11 +697,11 @@ export default function RiderCardPage() {

{t('labUi.rider.cardType')}

{([ - { id: 'rider', label: t('labUi.rider.licenseLabel'), icon: User }, - { id: 'park', label: t('labUi.rider.parkLabel'), icon: Building2 }, - { id: 'crew', label: t('cardUi.crew.crewCard'), icon: Users }, - { id: 'event', label: t('cardUi.event.pass'), icon: Ticket }, - ] as const).map(({ id, label, icon: Icon }) => ( + { id: 'rider' as const, label: t('labUi.rider.licenseLabel'), icon: User }, + ...(myVenues.length > 0 ? [{ id: 'park' as const, label: t('labUi.rider.parkLabel'), icon: Building2 }] : []), + ...(memberships.length > 0 ? [{ id: 'crew' as const, label: t('cardUi.crew.crewCard'), icon: Users }] : []), + ...(myEvents.length > 0 ? [{ id: 'event' as const, label: t('cardUi.event.pass'), icon: Ticket }] : []), + ]).map(({ id, label, icon: Icon }) => ( ))}
+ {(myVenues.length === 0 || memberships.length === 0 || myEvents.length === 0) && ( +

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

+ )} {/* Theme */} @@ -928,20 +934,29 @@ export default function RiderCardPage() {
- - -

{t('cardUi.venueHint')}

+ + {myVenues.length > 0 ? ( + <> + +

{t('cardUi.venueHint')}

+ + ) : ( +
+ {t('cardUi.parkNoVenue')}{' '} + {t('cardUi.parkAddVenue')} +
+ )}