SkateSpots/src/hooks/useMintDirectory.ts
SkateSpots 51e66727d9 feat(mints): surface MintRadar's community ratings, reliability and audit data
The directory we already fetch returns review ratings, reliability, uptime and
liveness-audit stats that the client was throwing away (it only typed ~12 of ~35
fields). Now:

- MintDirectoryEntry types the community fields (rating/weighted rating/count,
  reliabilityScore, uptime 24h+7d, audit volume + recent errors, serverLocation,
  units, mint/melt methods, pubkey, discoveredAt, lastError).
- Row shows the rating (e.g. 4.6 (30)) plus reliability and 24h uptime.
- Detail sheet shows rating, reliability, uptime, location, latency, NUTs,
  units, version, plus a liveness-audit line and an honest note that written
  reviews live on MintRadar (linked), not in the directory API.

Written comments are genuinely not in MintRadar's /api/mints/known (probed a few
plausible review endpoints - all 404), so the next step for in-app comments
would be Nostr-native mint reviews.
2026-09-29 18:02:57 +07:00

135 lines
4.8 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.

import { useQuery } from '@tanstack/react-query';
import { CapacitorHttp } from '@capacitor/core';
import { isNativePlatform } from '@/lib/platform';
/**
* A mint from the MintRadar community directory (https://mintradar.org).
*
* MintRadar sends no CORS headers, so on the WEB we fetch it **same-origin** at
* `/api/mints/*` — Netlify proxies it in prod (see `public/_redirects`) and the
* Vite proxy does in dev.
*
* On the NATIVE app the shell is served from `https://localhost`, so a relative
* `/api/...` path hits the **local asset server** and gets `index.html` back —
* which blew up as `Unexpected token '<' … is not valid JSON` on the Mints page.
* So native goes to the real site with Capacitor's native HTTP, which also
* bypasses WebView CORS (MintRadar has none).
*
* The data is unofficial, rate-limited and may change without notice — treat it
* as a hint, not gospel.
*/
export interface MintDirectoryEntry {
url: string;
name?: string;
iconUrl?: string;
descriptionLong?: string;
online?: boolean;
degraded?: boolean;
archived?: boolean;
latencyMs?: number;
version?: string;
nutCount?: number;
tosUrl?: string;
lastOnlineAt?: string;
// ── Community signal MintRadar also returns (previously unused) ────────────
/** Average community rating, 0–5. */
reviewAvgRating?: number;
/** How many community ratings back the average. */
reviewCount?: number;
/** Rating after MintRadar's own weighting. */
reviewWeightedRating?: number;
/** 0–100 composite reliability score. */
reliabilityScore?: number;
uptimePct24h?: number;
uptimePct7d?: number;
/** Liveness audit volume — tokens actually minted/melted through it. */
auditNMints?: number;
auditNMelts?: number;
auditNErrors?: number;
auditRecentErrors?: number;
auditRecentTotal?: number;
auditCheckedAt?: string;
serverLocation?: string;
units?: string[];
mintMethods?: Array<{ unit?: string; method?: string; min_amount?: number; max_amount?: number }>;
meltMethods?: Array<{ unit?: string; method?: string; min_amount?: number; max_amount?: number }>;
/** The mint's own Nostr announcement key, when it publishes one. */
pubkey?: string;
nostrAnnouncedAt?: string;
discoveredAt?: string;
lastError?: string | null;
}
const SITE_ORIGIN = 'https://skate-spots.org';
const DIRECTORY_PATH = '/api/mints/known';
const UNAVAILABLE = 'The mint directory is unavailable right now.';
/** Parse a JSON body, turning an HTML page into a friendly error. */
function parseDirectory(text: string): unknown {
const trimmed = text.trim();
if (trimmed.startsWith('<')) {
// An HTML page (SPA fallback or an upstream error) — never surface a raw
// "Unexpected token '<'" to the user.
throw new Error(UNAVAILABLE);
}
try {
return JSON.parse(trimmed);
} catch {
throw new Error('Unexpected mint directory response.');
}
}
function normalize(data: unknown): MintDirectoryEntry[] {
if (!Array.isArray(data)) throw new Error('Unexpected mint directory response.');
return (data as MintDirectoryEntry[]).filter(
(m) => m && typeof m.url === 'string' && m.url.length > 0,
);
}
async function loadDirectory(signal: AbortSignal): Promise<MintDirectoryEntry[]> {
// Web / dev: relative URL → same-origin proxy.
if (!isNativePlatform()) {
const res = await fetch(DIRECTORY_PATH, {
signal,
headers: { accept: 'application/json' },
});
if (!res.ok) throw new Error(`The mint directory is unavailable right now (HTTP ${res.status}).`);
return normalize(parseDirectory(await res.text()));
}
const absolute = `${SITE_ORIGIN}${DIRECTORY_PATH}`;
// Native: Capacitor's HTTP plugin (no CORS restrictions).
try {
const res = await CapacitorHttp.request({
url: absolute,
method: 'GET',
headers: { accept: 'application/json' },
connectTimeout: 15000,
readTimeout: 15000,
});
if (res.status >= 200 && res.status < 300) {
// CapacitorHttp parses JSON for us; a non-JSON body arrives as a string.
const raw: unknown = typeof res.data === 'string' ? parseDirectory(res.data) : res.data;
return normalize(raw);
}
console.warn('[mints] native HTTP returned', res.status);
} catch (e) {
console.warn('[mints] native HTTP failed:', e);
}
// Fallback: a plain fetch to the absolute URL (works on CORS-free WebViews).
const res = await fetch(absolute, { signal, headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`The mint directory is unavailable right now (HTTP ${res.status}).`);
return normalize(parseDirectory(await res.text()));
}
export function useMintDirectory() {
return useQuery<MintDirectoryEntry[]>({
queryKey: ['mint-directory'],
staleTime: 10 * 60 * 1000,
retry: 1,
queryFn: (c) => loadDirectory(AbortSignal.any([c.signal, AbortSignal.timeout(20000)])),
});
}