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
|
watermark**. Card pages resolve the profile via **`useCardProfile`** (tolerant
|
||||||
JSON over `querySkateContent`, so it can't drop a real profile the way
|
JSON over `querySkateContent`, so it can't drop a real profile the way
|
||||||
`useAuthor` could); `DEFAULT_RIDER_CARD.style` is now `''` (no fake "Street").
|
`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
|
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
|
||||||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||||||
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
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
|
**Venue binding (`a`) — REQUIRED for new passes:** it points at the park's own
|
||||||
park's own spot (kind 30818). The `/verify` page fetches that spot and confirms
|
spot (kind 30818). A pass is only issuable by the **key that owns that venue**,
|
||||||
its **author pubkey equals the pass's signer** — i.e. the pass was issued by the
|
and the `/verify` page fetches the spot and confirms its **author pubkey equals
|
||||||
key that owns the venue. A pass with no `a` tag still verifies as a signature,
|
the pass's signer**. The `/card` studio only offers the Park type when you own a
|
||||||
but is shown as **not bound to a venue** (any key can mint one), so a park that
|
venue, and won't publish without one. (Legacy passes with no `a` still verify as
|
||||||
cares about authenticity should always set it.
|
a signature but are shown as **not venue-bound**.)
|
||||||
|
|
||||||
### Card Themes
|
### Card Themes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6331,6 +6331,10 @@ walletUi: {
|
||||||
venue: 'Linked venue (optional)',
|
venue: 'Linked venue (optional)',
|
||||||
venueNone: 'No venue',
|
venueNone: 'No venue',
|
||||||
venueHint: 'Pick one of your own spots to bind this pass to a venue. A verifier will confirm you own it.',
|
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: {
|
facets: {
|
||||||
title: 'Show identity facets',
|
title: 'Show identity facets',
|
||||||
crews: 'Crews',
|
crews: 'Crews',
|
||||||
|
|
@ -6393,6 +6397,7 @@ walletUi: {
|
||||||
checkName: 'Display name',
|
checkName: 'Display name',
|
||||||
checkProfile: 'Styles & level',
|
checkProfile: 'Styles & level',
|
||||||
checkCrew: 'Join a crew',
|
checkCrew: 'Join a crew',
|
||||||
|
moreTypes: 'More card types unlock as you map spots you own, join crews, or RSVP to events.',
|
||||||
accent: 'Accent colour',
|
accent: 'Accent colour',
|
||||||
accentHint: 'Overrides the theme accent',
|
accentHint: 'Overrides the theme accent',
|
||||||
accentClear: 'Use theme colour',
|
accentClear: 'Use theme colour',
|
||||||
|
|
@ -6423,6 +6428,7 @@ walletUi: {
|
||||||
kindEvent: 'Event pass',
|
kindEvent: 'Event pass',
|
||||||
expired: 'Expired',
|
expired: 'Expired',
|
||||||
valid: 'Valid',
|
valid: 'Valid',
|
||||||
|
holder: 'Holder',
|
||||||
issuedBy: 'Issued by',
|
issuedBy: 'Issued by',
|
||||||
accessLevel: 'Access level',
|
accessLevel: 'Access level',
|
||||||
validThru: 'Valid thru',
|
validThru: 'Valid thru',
|
||||||
|
|
|
||||||
|
|
@ -6331,6 +6331,10 @@ walletUi: {
|
||||||
venue: 'Sede vinculada (opcional)',
|
venue: 'Sede vinculada (opcional)',
|
||||||
venueNone: 'Sin sede',
|
venueNone: 'Sin sede',
|
||||||
venueHint: 'Elige uno de tus propios spots para vincular este pase a una sede. Un verificador confirmará que es tuya.',
|
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: {
|
facets: {
|
||||||
title: 'Mostrar facetas de identidad',
|
title: 'Mostrar facetas de identidad',
|
||||||
crews: 'Cuadrillas',
|
crews: 'Cuadrillas',
|
||||||
|
|
@ -6393,6 +6397,7 @@ walletUi: {
|
||||||
checkName: 'Nombre visible',
|
checkName: 'Nombre visible',
|
||||||
checkProfile: 'Estilos y nivel',
|
checkProfile: 'Estilos y nivel',
|
||||||
checkCrew: 'Únete a una cuadrilla',
|
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',
|
accent: 'Color de acento',
|
||||||
accentHint: 'Sustituye el acento del tema',
|
accentHint: 'Sustituye el acento del tema',
|
||||||
accentClear: 'Usar color del tema',
|
accentClear: 'Usar color del tema',
|
||||||
|
|
@ -6423,6 +6428,7 @@ walletUi: {
|
||||||
kindEvent: 'Pase de evento',
|
kindEvent: 'Pase de evento',
|
||||||
expired: 'Caducado',
|
expired: 'Caducado',
|
||||||
valid: 'Válido',
|
valid: 'Válido',
|
||||||
|
holder: 'Titular',
|
||||||
issuedBy: 'Emitido por',
|
issuedBy: 'Emitido por',
|
||||||
accessLevel: 'Nivel de acceso',
|
accessLevel: 'Nivel de acceso',
|
||||||
validThru: 'Válido hasta',
|
validThru: 'Válido hasta',
|
||||||
|
|
|
||||||
|
|
@ -503,21 +503,24 @@ export default function RiderCardPage() {
|
||||||
}, [renderCardToImage, cardData.name, cardData.npub, toast, t]);
|
}, [renderCardToImage, cardData.name, cardData.npub, toast, t]);
|
||||||
|
|
||||||
const handlePublishParkCard = useCallback(() => {
|
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 d = `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`;
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
['d', d],
|
['d', d],
|
||||||
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
|
['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]);
|
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 }, {
|
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
|
||||||
onSuccess: () => { setSavedParkD(d); 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' }),
|
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 ────────────────────────────────────────────────────────────
|
// ── Logged out ────────────────────────────────────────────────────────────
|
||||||
if (!user) {
|
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>
|
<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">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
{([
|
{([
|
||||||
{ id: 'rider', label: t('labUi.rider.licenseLabel'), icon: User },
|
{ id: 'rider' as const, label: t('labUi.rider.licenseLabel'), icon: User },
|
||||||
{ id: 'park', label: t('labUi.rider.parkLabel'), icon: Building2 },
|
...(myVenues.length > 0 ? [{ id: 'park' as const, label: t('labUi.rider.parkLabel'), icon: Building2 }] : []),
|
||||||
{ id: 'crew', label: t('cardUi.crew.crewCard'), icon: Users },
|
...(memberships.length > 0 ? [{ id: 'crew' as const, label: t('cardUi.crew.crewCard'), icon: Users }] : []),
|
||||||
{ id: 'event', label: t('cardUi.event.pass'), icon: Ticket },
|
...(myEvents.length > 0 ? [{ id: 'event' as const, label: t('cardUi.event.pass'), icon: Ticket }] : []),
|
||||||
] as const).map(({ id, label, icon: Icon }) => (
|
]).map(({ id, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => setCardType(id)}
|
onClick={() => setCardType(id)}
|
||||||
|
|
@ -714,6 +717,9 @@ export default function RiderCardPage() {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Theme */}
|
{/* Theme */}
|
||||||
|
|
@ -928,20 +934,29 @@ export default function RiderCardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('cardUi.venue')}</Label>
|
<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)}>
|
{myVenues.length > 0 ? (
|
||||||
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue placeholder={t('cardUi.venueNone')} /></SelectTrigger>
|
<>
|
||||||
<SelectContent>
|
<Select value={venueId} onValueChange={setVenueId}>
|
||||||
<SelectItem value="__none">{t('cardUi.venueNone')}</SelectItem>
|
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue placeholder={t('cardUi.venuePick')} /></SelectTrigger>
|
||||||
{myVenues.map(s => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
|
<SelectContent>
|
||||||
</SelectContent>
|
{myVenues.map(s => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
|
||||||
</Select>
|
</SelectContent>
|
||||||
<p className="mt-1.5 text-[10px] text-gray-600">{t('cardUi.venueHint')}</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePublishParkCard}
|
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" />
|
<Zap className="h-4 w-4" />
|
||||||
{t('labUi.rider.publishToNostr')}
|
{t('labUi.rider.publishToNostr')}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ export default function VerifyCardPage() {
|
||||||
const venue = valid?.venue;
|
const venue = valid?.venue;
|
||||||
const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1];
|
const validThru = event?.tags.find(([n]) => n === 'valid-thru')?.[1];
|
||||||
const expiry = passExpiry(validThru);
|
const expiry = passExpiry(validThru);
|
||||||
|
const holderPubkey = event?.tags.find(([n]) => n === 'p')?.[1];
|
||||||
|
const holder = holderPubkey ? nip19.npubEncode(holderPubkey) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto px-4 py-10">
|
<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} />
|
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
|
||||||
{event.kind === PARK_ACCESS_CARD_KIND && (
|
{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] ?? '—'} />
|
<Row label={t('cardUi.verify.accessLevel')} value={event.tags.find(([n]) => n === 'access-level')?.[1] ?? '—'} />
|
||||||
{validThru && (
|
{validThru && (
|
||||||
<Row
|
<Row
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue