SkateSpots/src/hooks/useRiderCard.ts
SkateSpots a6dd32e653 feat(card): public pass hint + shop/listing pass badges (audit items 2 & 4)
- 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).
2026-10-02 15:05:49 +07:00

325 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
},
});
}