From 6a442c394f18df7e5d99fbc3a8db2d2bc59b519b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 17:25:16 +0700 Subject: [PATCH] fix(mints): native app fetched /api/mints from the local shell -> HTML/JSON error On the native app the shell is served from https://localhost, so the relative /api/mints/known hit the local asset server and returned index.html, surfacing as 'Unexpected token < ... not valid JSON' on /mints. - Native now fetches https://skate-spots.org/api/mints/known via CapacitorHttp (bundled core plugin, no WebView CORS), with a plain-fetch fallback. - Web/dev unchanged (same-origin relative path via the Netlify redirect / Vite proxy). - HTML or unparseable bodies now become a friendly 'unavailable' message instead of a raw JSON parse error. --- AGENTS.md | 9 ++++ src/hooks/useMintDirectory.ts | 97 ++++++++++++++++++++++++++++------- 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e50b4a0..8e65aa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -248,6 +248,15 @@ session with **no other memory of this project**. So: at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → https://mintradar.org/api/mints/:splat 200`), dev via `server.proxy` in `vite.config.ts`. Data is unofficial/rate-limited; adding a mint is native-only. + ⚠️ **Relative `/api/*` paths don't work in the native app** (fixed 2026-09-27): + the shell is served from `https://localhost`, so `/api/mints/known` hits the + LOCAL asset server and returns `index.html` → `Unexpected token '<' … not valid + JSON`. `useMintDirectory` now uses `https://skate-spots.org/api/mints/known` + via **CapacitorHttp** on native (core plugin, no CORS) with a plain-fetch + fallback, and turns an HTML body into a friendly message. Same pattern already + used by `sasquatchBrain.ts` / `useSasquatchAiStatus` (`Capacitor.isNativePlatform() + ? 'https://skate-spots.org/api/…' : '/api/…'`) — follow it for any new `/api` + call, and never let a raw JSON parse error reach the UI. - **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) — retries, then one full reload per session, so a chunk-load failure self-heals instead of showing the error boundary. diff --git a/src/hooks/useMintDirectory.ts b/src/hooks/useMintDirectory.ts index 4765b2b..509b40c 100644 --- a/src/hooks/useMintDirectory.ts +++ b/src/hooks/useMintDirectory.ts @@ -1,12 +1,22 @@ 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). * - * Fetched **same-origin** at `/api/mints/*` — MintRadar has no CORS, so prod - * proxies it via a Netlify redirect (see `public/_redirects`) and dev via the - * Vite proxy in `vite.config.ts`. The data is unofficial, rate-limited and may - * change without notice — treat it as a hint, not gospel. + * 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; @@ -23,24 +33,75 @@ export interface MintDirectoryEntry { lastOnlineAt?: string; } +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 { + // 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({ queryKey: ['mint-directory'], staleTime: 10 * 60 * 1000, retry: 1, - queryFn: async (c) => { - const res = await fetch('/api/mints/known', { - signal: AbortSignal.any([c.signal, AbortSignal.timeout(15000)]), - headers: { accept: 'application/json' }, - }); - if (!res.ok) { - throw new Error(`The mint directory is unavailable right now (HTTP ${res.status}).`); - } - const data = (await res.json()) as unknown; - 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, - ); - }, + queryFn: (c) => loadDirectory(AbortSignal.any([c.signal, AbortSignal.timeout(20000)])), }); }