feat(card): Park-pass issuance logic + gate card types by availability

- Park Access is only offered if you own a venue (a spot you authored), and
  publishing REQUIRES a venue binding — only the venue owner's key can issue it.
  The pass names the holder; /verify shows holder + venue match + expiry.
- Card types are now gated by what you can actually issue/derive (Park/ Crew /
  Event), with a note explaining how to unlock more.
- Park panel shows a 'map a spot you own' prompt when you have no venue; the
  Publish button is disabled until a venue + holder are set.
- NIP.md: venue binding is required for new passes. i18n EN/ES.
This commit is contained in:
SkateSpots 2026-10-02 12:42:53 +07:00
parent 80e9552d11
commit 9f3278f135
6 changed files with 64 additions and 26 deletions

View file

@ -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

12
NIP.md
View file

@ -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

View file

@ -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',

View file

@ -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',

View file

@ -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() {
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('labUi.rider.cardType')}</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{([
{ 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 }) => (
<button
key={id}
onClick={() => setCardType(id)}
@ -714,6 +717,9 @@ export default function RiderCardPage() {
</button>
))}
</div>
{(myVenues.length === 0 || memberships.length === 0 || myEvents.length === 0) && (
<p className="mt-2 text-[10px] leading-relaxed text-gray-600">{t('cardUi.studio.moreTypes')}</p>
)}
</div>
{/* Theme */}
@ -928,20 +934,29 @@ export default function RiderCardPage() {
</div>
<div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('cardUi.venue')}</Label>
<Select value={venueId || '__none'} onValueChange={(v) => setVenueId(v === '__none' ? '' : v)}>
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue placeholder={t('cardUi.venueNone')} /></SelectTrigger>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('cardUi.venue')} *</Label>
{myVenues.length > 0 ? (
<>
<Select value={venueId} onValueChange={setVenueId}>
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue placeholder={t('cardUi.venuePick')} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('cardUi.venueNone')}</SelectItem>
{myVenues.map(s => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
</SelectContent>
</Select>
<p className="mt-1.5 text-[10px] text-gray-600">{t('cardUi.venueHint')}</p>
</>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-[11px] text-amber-400">
{t('cardUi.parkNoVenue')}{' '}
<Link to="/create-spot" className="underline">{t('cardUi.parkAddVenue')}</Link>
</div>
)}
</div>
<Button
onClick={handlePublishParkCard}
className="h-12 w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#b8e600]"
disabled={!venueSpot || !recipientNpub.trim()}
className="h-12 w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#b8e600] disabled:opacity-40"
>
<Zap className="h-4 w-4" />
{t('labUi.rider.publishToNostr')}

View file

@ -97,6 +97,8 @@ export default function VerifyCardPage() {
const venue = valid?.venue;
const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1];
const expiry = passExpiry(validThru);
const holderPubkey = event?.tags.find(([n]) => n === 'p')?.[1];
const holder = holderPubkey ? nip19.npubEncode(holderPubkey) : '';
return (
<div className="max-w-lg mx-auto px-4 py-10">
@ -133,6 +135,9 @@ export default function VerifyCardPage() {
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
{event.kind === PARK_ACCESS_CARD_KIND && (
<>
{holder && (
<Row label={t('cardUi.verify.holder')} value={`${holder.slice(0, 14)}…${holder.slice(-6)}`} />
)}
<Row label={t('cardUi.verify.accessLevel')} value={event.tags.find(([n]) => n === 'access-level')?.[1] ?? '—'} />
{validThru && (
<Row