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:
parent
80e9552d11
commit
9f3278f135
6 changed files with 64 additions and 26 deletions
|
|
@ -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
12
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
{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')}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue