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.
135 lines
4.8 KiB
TypeScript
135 lines
4.8 KiB
TypeScript
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)])),
|
||
});
|
||
}
|