SkateSpots/src/hooks/useMarketplace.ts
SkateSpots c79373ed05 feat(offline): the map survives a dead relay (and says so) + skater@skate-spots.org
Relay outages no longer look like 'no spots'. querySkateContent swallowed every
relay error and returned [], so a dead relay would blank the map AND overwrite
good cached data with an empty list (then persist it). Now:

- querySkateContent/queryCanonical take an opt-in 'requireAny' that throws when
  NO relay answered. Only the map's own feeds use it, so the blast radius is
  small (79 other call sites keep today's behaviour).
- useSkateSpots/useSkateSpot fall back to the local IndexedDB cache on failure,
  so the map still works at the park; they only error when nothing is saved.
- The persister refuses to persist an empty array, so an outage can't wipe the
  last-known map on the next cold start.
- MapPage shows an honest 'Offline · showing saved spots' banner (reusing the
  existing relayProbe/useRelayStatus) and a 'Can't reach the relays' card
  instead of falsely claiming 'No spots mapped yet'.

Also seeds skater@skate-spots.org for the second admin (caba497f…) and reserves
the name so nobody else can claim it.
2026-09-28 12:48:54 +07:00

115 lines
4 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
const STALE_TIME = 5 * 60 * 1000; // 5 min
const GC_TIME = 30 * 60 * 1000; // 30 min
const TIMEOUT = 8000;
export function useMarketplaceItems(limit = 100, options: { includeHidden?: boolean } = {}) {
const { includeHidden = false } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['marketplace-items', limit, getQueryKeySuffix()],
// `includeHidden`: admin tooling only (review queue + undo).
select: (items: MarketplaceItem[]) =>
includeHidden ? items : filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT);
const events = await querySkateContent(
[
{
kinds: [MARKETPLACE_ITEM_KIND],
'#t': ['skateboarding'],
limit,
},
],
{ signal, requireAny: true }
);
const items: MarketplaceItem[] = [];
for (const event of events) {
// Skip showcase-only Stash items (public gear that is NOT for sale):
// they belong on the profile's public Stash, not the sellable marketplace.
const isShowcaseOnly = event.tags.some((t) => t[0] === 'showcase' && t[1] === 'true');
if (isShowcaseOnly) continue;
const item = parseMarketplaceItem(event);
if (item) items.push(item);
}
return items.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: STALE_TIME,
gcTime: GC_TIME,
refetchOnMount: true,
});
}
export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, limit = 20) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()],
select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT);
const spotRef = `30818:${spotPubkey}:${spotIdentifier}`;
const events = await querySkateContent(
[
{
kinds: [MARKETPLACE_ITEM_KIND],
'#a': [spotRef],
'#t': ['skateboarding'],
limit,
},
],
{ signal }
);
const items: MarketplaceItem[] = [];
for (const event of events) {
const isShowcaseOnly = event.tags.some((t) => t[0] === 'showcase' && t[1] === 'true');
if (isShowcaseOnly) continue;
const item = parseMarketplaceItem(event);
if (item && (!item.status || item.status === 'active')) {
items.push(item);
}
}
return items.sort((a, b) => b.createdAt - a.createdAt);
},
enabled: !!spotPubkey && !!spotIdentifier,
staleTime: STALE_TIME,
gcTime: GC_TIME,
refetchOnMount: true,
});
}
/** Fetch a single marketplace listing by author + `d` (including sold/inactive). */
export function useMarketplaceItem(pubkey?: string, identifier?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['marketplace-item', pubkey, identifier, getQueryKeySuffix()],
enabled: !!pubkey && !!identifier,
staleTime: STALE_TIME,
gcTime: GC_TIME,
queryFn: async (): Promise<MarketplaceItem | null> => {
if (!pubkey || !identifier) return null;
const signal = AbortSignal.timeout(TIMEOUT);
const events = await querySkateContent(
[{ kinds: [MARKETPLACE_ITEM_KIND], authors: [pubkey], '#d': [identifier], limit: 5 }],
{ signal },
);
const newest = [...events].sort((a, b) => b.created_at - a.created_at)[0];
return newest ? parseMarketplaceItem(newest) : null;
},
});
}