- VenueAccessCard now has a visitor branch: a public 'this venue issues access passes' hint (count only, never a holder list) shown only when the venue has active passes. Rendered for everyone on shops and gated spots. - New shared, cached useVenuesWithPasses() hook resolves active pass coords + owners in one query. - SpotCard badges shops that issue passes; MarketplaceItemCard flags sellers whose venue issues passes (title explains it is the venue, not the listing). - i18n: venueAccess.publicTitle/publicDesc/badge/sellerHint (EN + ES).
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
||
* 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, useQueryClient } from '@tanstack/react-query';
|
||
import { useNostr } from '@nostrify/react';
|
||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
import type { NostrEvent } from '@nostrify/nostrify';
|
||
import {
|
||
RIDER_CARD_KIND,
|
||
RIDER_CARD_D,
|
||
SKILL_BADGE_KIND,
|
||
RIDER_ROLE_KINDS,
|
||
RIDER_ROLE_META,
|
||
PARK_ACCESS_CARD_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<RiderCardResult>({
|
||
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<RiderBadge[]>({
|
||
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,
|
||
}));
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The Lab roles a skater wears (kinds 35001–35014). Derived live from the
|
||
* author's own Lab profiles, so the card never claims a role they don't have.
|
||
*/
|
||
export function useRiderRoles(pubkey: string | undefined) {
|
||
const { nostr } = useNostr();
|
||
|
||
return useQuery<string[]>({
|
||
queryKey: ['rider-roles', 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: RIDER_ROLE_KINDS, authors: [pubkey], limit: 50 }],
|
||
{ signal },
|
||
);
|
||
const kinds = new Set(events.map((e) => e.kind));
|
||
return RIDER_ROLE_META.filter((r) => kinds.has(r.kind)).map((r) => r.role);
|
||
},
|
||
});
|
||
}
|
||
|
||
export interface CardProfile {
|
||
name?: string;
|
||
picture?: string;
|
||
nip05?: string;
|
||
created_at?: number;
|
||
}
|
||
|
||
/**
|
||
* The profile fields a card needs (name/avatar/nip05), fetched from the app's
|
||
* OWN relays via `querySkateContent` with a tolerant JSON parse. `useAuthor`
|
||
* misses profiles that only live on the community relay and can drop a profile
|
||
* its strict schema doesn't like — so the card uses this instead.
|
||
*/
|
||
export function useCardProfile(pubkey: string | undefined) {
|
||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||
|
||
return useQuery<CardProfile>({
|
||
queryKey: ['card-profile', pubkey, getQueryKeySuffix()],
|
||
enabled: !!pubkey,
|
||
staleTime: 5 * 60_000,
|
||
queryFn: async (c) => {
|
||
if (!pubkey) return {};
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(9000)]);
|
||
const events = await querySkateContent(
|
||
[{ kinds: [0], authors: [pubkey], limit: 1 }],
|
||
{ signal },
|
||
);
|
||
const ev = [...events].sort((a, b) => b.created_at - a.created_at)[0];
|
||
if (!ev) return {};
|
||
try {
|
||
const m = JSON.parse(ev.content) as Record<string, unknown>;
|
||
return {
|
||
name: (m.display_name as string) || (m.name as string),
|
||
picture: m.picture as string,
|
||
nip05: m.nip05 as string,
|
||
created_at: ev.created_at,
|
||
};
|
||
} catch {
|
||
return {};
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
export interface IssuedPass {
|
||
id: string;
|
||
d: string;
|
||
coord: string;
|
||
holder?: string;
|
||
accessLevel?: string;
|
||
issuer?: string;
|
||
validThru?: string;
|
||
status?: string;
|
||
createdAt: number;
|
||
event: NostrEvent;
|
||
}
|
||
|
||
/** Access passes this key has ISSUED (kind 35679, authored by them). */
|
||
export function useIssuedPasses(pubkey?: string) {
|
||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||
|
||
return useQuery<IssuedPass[]>({
|
||
queryKey: ['issued-passes', pubkey, getQueryKeySuffix()],
|
||
enabled: !!pubkey,
|
||
staleTime: 60_000,
|
||
queryFn: async () => {
|
||
if (!pubkey) return [];
|
||
const signal = AbortSignal.timeout(9000);
|
||
const events = await querySkateContent(
|
||
[{ kinds: [PARK_ACCESS_CARD_KIND], authors: [pubkey], limit: 100 }],
|
||
{ signal },
|
||
);
|
||
// Newest version per d-tag — a revoke re-publishes the SAME d, so without
|
||
// this the issued list would show both the original and the revoked pass.
|
||
const byD = new Map<string, NostrEvent>();
|
||
for (const ev of events) {
|
||
const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id;
|
||
const cur = byD.get(d);
|
||
if (!cur || ev.created_at > cur.created_at) byD.set(d, ev);
|
||
}
|
||
return [...byD.values()]
|
||
.map((ev) => ({
|
||
id: ev.id,
|
||
d: ev.tags.find(([n]) => n === 'd')?.[1] ?? '',
|
||
coord: ev.tags.find(([n]) => n === 'a')?.[1] ?? '',
|
||
holder: ev.tags.find(([n]) => n === 'p')?.[1],
|
||
accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1],
|
||
issuer: ev.tags.find(([n]) => n === 'issuer')?.[1],
|
||
validThru: ev.tags.find(([n]) => n === 'valid-thru')?.[1],
|
||
status: ev.tags.find(([n]) => n === 'status')?.[1],
|
||
createdAt: ev.created_at,
|
||
event: ev,
|
||
}))
|
||
.sort((a, b) => b.createdAt - a.createdAt);
|
||
},
|
||
});
|
||
}
|
||
|
||
export interface VenuePassIndex {
|
||
/** Venue coords (`<kind>:<pubkey>:<d>`) with at least one active pass. */
|
||
coords: Set<string>;
|
||
/** Pubkeys that own at least one venue with an active pass. */
|
||
owners: Set<string>;
|
||
}
|
||
|
||
/**
|
||
* One cached query for the whole app: which venues currently have active access
|
||
* passes. Lets a card list badge shops without a relay query per card. A pass is
|
||
* active unless its newest version carries `status=revoked` (addressable
|
||
* supersede — the same rule `/verify` and "My passes" use).
|
||
*/
|
||
export function useVenuesWithPasses() {
|
||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||
|
||
return useQuery<VenuePassIndex>({
|
||
queryKey: ['venues-with-passes', getQueryKeySuffix()],
|
||
staleTime: 120_000,
|
||
queryFn: async () => {
|
||
const signal = AbortSignal.timeout(9000);
|
||
const events = await querySkateContent(
|
||
[{ kinds: [PARK_ACCESS_CARD_KIND], limit: 500 }],
|
||
{ signal },
|
||
);
|
||
const byD = new Map<string, NostrEvent>();
|
||
for (const ev of events) {
|
||
const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id;
|
||
const cur = byD.get(d);
|
||
if (!cur || ev.created_at > cur.created_at) byD.set(d, ev);
|
||
}
|
||
const coords = new Set<string>();
|
||
const owners = new Set<string>();
|
||
for (const ev of byD.values()) {
|
||
if (ev.tags.find(([n]) => n === 'status')?.[1] === 'revoked') continue;
|
||
const a = ev.tags.find(([n]) => n === 'a')?.[1];
|
||
if (!a) continue;
|
||
coords.add(a);
|
||
const owner = a.split(':')[1];
|
||
if (owner) owners.add(owner);
|
||
}
|
||
return { coords, owners };
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Revoke an issued pass: re-publish it under the SAME `d` with `status=revoked`,
|
||
* which supersedes it by addressable replacement (works on every relay, unlike
|
||
* a NIP-09 deletion others may ignore).
|
||
*/
|
||
export function useRevokePass() {
|
||
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||
const queryClient = useQueryClient();
|
||
return {
|
||
isPending,
|
||
revoke: async (pass: IssuedPass) => {
|
||
const tags = pass.event.tags.filter(([n]) => n !== 'status').map((t) => [...t]);
|
||
tags.push(['status', 'revoked']);
|
||
await publish({ kind: PARK_ACCESS_CARD_KIND, content: '', tags });
|
||
queryClient.invalidateQueries({ queryKey: ['issued-passes'] });
|
||
},
|
||
};
|
||
}
|
||
|
||
export interface ReceivedPass {
|
||
id: string;
|
||
d: string;
|
||
issuer: string;
|
||
accessLevel?: string;
|
||
validThru?: string;
|
||
status?: string;
|
||
targetCoord?: string;
|
||
targetKind?: number;
|
||
event: NostrEvent;
|
||
}
|
||
|
||
/** Access passes issued TO this key (kind 35679 with `#p` = them). */
|
||
export function useReceivedPasses(pubkey?: string) {
|
||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||
|
||
return useQuery<ReceivedPass[]>({
|
||
queryKey: ['received-passes', pubkey, getQueryKeySuffix()],
|
||
enabled: !!pubkey,
|
||
staleTime: 60_000,
|
||
queryFn: async () => {
|
||
if (!pubkey) return [];
|
||
const signal = AbortSignal.timeout(9000);
|
||
const events = await querySkateContent(
|
||
[{ kinds: [PARK_ACCESS_CARD_KIND], '#p': [pubkey], limit: 200 }],
|
||
{ signal },
|
||
);
|
||
// Newest version per d-tag (a pass can be superseded by a revocation).
|
||
const byD = new Map<string, NostrEvent>();
|
||
for (const ev of events) {
|
||
const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id;
|
||
const cur = byD.get(d);
|
||
if (!cur || ev.created_at > cur.created_at) byD.set(d, ev);
|
||
}
|
||
return [...byD.values()]
|
||
.map((ev) => {
|
||
const targetCoord = ev.tags.find(([n]) => n === 'a')?.[1];
|
||
return {
|
||
id: ev.id,
|
||
d: ev.tags.find(([n]) => n === 'd')?.[1] ?? '',
|
||
issuer: ev.tags.find(([n]) => n === 'issuer')?.[1] ?? '',
|
||
accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1],
|
||
validThru: ev.tags.find(([n]) => n === 'valid-thru')?.[1],
|
||
status: ev.tags.find(([n]) => n === 'status')?.[1],
|
||
targetCoord,
|
||
targetKind: targetCoord ? Number(targetCoord.split(':')[0]) : undefined,
|
||
event: ev,
|
||
};
|
||
})
|
||
.sort((a, b) => b.event.created_at - a.event.created_at);
|
||
},
|
||
});
|
||
}
|