diff --git a/AGENTS.md b/AGENTS.md index 55af41b..8f90898 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,11 @@ session with **no other memory of this project**. So: - **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`). - **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`). +- **Mints directory**: `/mints` page lists Cashu mints from the community + **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin + 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. - **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/public/_headers b/public/_headers index 63cff23..bdf7196 100644 --- a/public/_headers +++ b/public/_headers @@ -4,3 +4,7 @@ X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: geolocation=(self), camera=(self), microphone=() + +# Cache the proxied mint directory so we don't hammer the rate-limited API. +/api/mints/* + Cache-Control: public, max-age=600 diff --git a/public/_redirects b/public/_redirects index f824337..efc7f52 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1 +1,6 @@ -/* /index.html 200 \ No newline at end of file +# MintRadar proxy: the browser can't call mintradar.org directly (no CORS), so +# proxy same-origin server-side. Also hides users' IPs and lets us cache. +/api/mints/* https://mintradar.org/api/mints/:splat 200 + +# SPA fallback (must stay last). +/* /index.html 200 diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index a6b1c75..196f629 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -134,6 +134,7 @@ const ROUTES = [ '/stash', '/collections', '/stats', + '/mints', '/messages/group/abc123', ]; const problems = []; diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 06ba128..be852e0 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -15,6 +15,7 @@ const Index = lazyWithRetry(() => import("./pages/Index")); const EventsPage = lazyWithRetry(() => import("./pages/EventsPage")); const LabPage = lazyWithRetry(() => import("./pages/LabPage")); const WalletPage = lazyWithRetry(() => import("./pages/WalletPage")); +const MintsPage = lazyWithRetry(() => import("./pages/MintsPage")); const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage")); const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage")); const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage")); @@ -123,6 +124,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/hooks/useMintDirectory.ts b/src/hooks/useMintDirectory.ts new file mode 100644 index 0000000..4765b2b --- /dev/null +++ b/src/hooks/useMintDirectory.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; + +/** + * 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. + */ +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; +} + +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, + ); + }, + }); +} diff --git a/src/hooks/useNavPreferences.ts b/src/hooks/useNavPreferences.ts index 2c03ae5..76efada 100644 --- a/src/hooks/useNavPreferences.ts +++ b/src/hooks/useNavPreferences.ts @@ -9,6 +9,7 @@ import { Bookmark, MessageCircle, BarChart3, + Coins, type LucideIcon, } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; @@ -57,6 +58,7 @@ export const NAV_CATALOG: NavTab[] = [ { id: 'events', href: '/events', labelKey: 'nav.events', label: 'Events', icon: Calendar }, { id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users }, { id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true }, + { id: 'mints', href: '/mints', label: 'Mints', icon: Coins }, { id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true }, { id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true }, { id: 'contacts', href: '/contacts', label: 'Contacts', icon: Users, authOnly: true }, diff --git a/src/pages/MintsPage.tsx b/src/pages/MintsPage.tsx new file mode 100644 index 0000000..ae97d38 --- /dev/null +++ b/src/pages/MintsPage.tsx @@ -0,0 +1,266 @@ +import { useMemo, useState } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { Link } from 'react-router-dom'; +import { Layout } from '@/components/Layout'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useMintDirectory, type MintDirectoryEntry } from '@/hooks/useMintDirectory'; +import { useCashu } from '@/hooks/wallet/useCashu'; +import { useToast } from '@/hooks/useToast'; +import { isNativePlatform } from '@/lib/platform'; +import { cn } from '@/lib/utils'; +import { Search, Coins, ExternalLink, Plus, Loader2, Check, ShieldAlert, Activity } from 'lucide-react'; + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +function healthBadge(mint: MintDirectoryEntry) { + if (mint.archived) { + return { label: 'Archived', className: 'border-gray-600 bg-gray-800/60 text-gray-400' }; + } + if (mint.degraded) { + return { label: 'Degraded', className: 'border-amber-500/40 bg-amber-500/10 text-amber-400' }; + } + if (mint.online === false) { + return { label: 'Offline', className: 'border-red-500/40 bg-red-500/10 text-red-400' }; + } + return { label: 'Online', className: 'border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]' }; +} + +function MintRow({ + mint, + added, + canAdd, + adding, + onAdd, +}: { + mint: MintDirectoryEntry; + added: boolean; + canAdd: boolean; + adding: boolean; + onAdd: () => void; +}) { + const health = healthBadge(mint); + return ( + + +
+ {mint.iconUrl ? ( + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( +
+ +
+ )} + +
+
+

{mint.name || hostOf(mint.url)}

+ + {health.label} + + {typeof mint.latencyMs === 'number' && mint.online && ( + + {mint.latencyMs} ms + + )} +
+

{hostOf(mint.url)}

+ {mint.descriptionLong && ( +

{mint.descriptionLong}

+ )} +
+ {typeof mint.nutCount === 'number' && {mint.nutCount} NUTs} + {mint.version && {mint.version}} + {mint.tosUrl && ( + + Terms + + )} +
+
+ +
+ {added ? ( + + Added + + ) : canAdd ? ( + + ) : null} +
+
+
+
+ ); +} + +export default function MintsPage() { + const { data, isLoading, error } = useMintDirectory(); + const { coco, mints, isInitialized, refreshMints } = useCashu(); + const { toast } = useToast(); + const [query, setQuery] = useState(''); + const [addingUrl, setAddingUrl] = useState(null); + + useSeoMeta({ + title: 'Cashu Mints — SkateSpots', + description: 'Browse Cashu mints, check their health, and add one to your wallet.', + }); + + const canAdd = isNativePlatform() && isInitialized && !!coco; + const addedUrls = useMemo( + () => new Set(mints.map((m) => m.url.toLowerCase())), + [mints], + ); + + const filtered = useMemo(() => { + const list = data ?? []; + const q = query.trim().toLowerCase(); + return list + .filter((m) => { + if (!q) return true; + return ( + m.url.toLowerCase().includes(q) || + (m.name ?? '').toLowerCase().includes(q) + ); + }) + .sort((a, b) => { + const rank = (m: MintDirectoryEntry) => + m.archived ? 3 : m.online === false ? 2 : m.degraded ? 1 : 0; + const r = rank(a) - rank(b); + if (r !== 0) return r; + return (a.latencyMs ?? 9999) - (b.latencyMs ?? 9999); + }); + }, [data, query]); + + const handleAdd = async (url: string) => { + if (!coco) return; + setAddingUrl(url); + try { + await coco.mint.addMint(url, { trusted: true }); + await refreshMints(); + toast({ title: 'Mint added', description: `${hostOf(url)} is now in your wallet.` }); + } catch (e) { + toast({ + title: 'Could not add mint', + description: e instanceof Error ? e.message : 'Something went wrong.', + variant: 'destructive', + }); + } finally { + setAddingUrl(null); + } + }; + + return ( + +
+
+

+ Mints +

+

+ Browse Cashu mints and their health. Cashu wallet operations run in the + SkateSpots app. +

+
+ +
+ + setQuery(e.target.value)} + placeholder="Search mints by name or URL…" + className="h-11 border-gray-700 bg-gray-900 pl-10 text-white" + /> +
+ + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {error && !isLoading && ( + + + +

+ {error instanceof Error ? error.message : 'Could not load the mint directory.'} +

+

+ The directory is community-run and rate-limited — it may be briefly + unavailable. +

+ +
+
+ )} + + {!isLoading && !error && ( + <> + {!canAdd && ( +

+ Adding a mint happens in the SkateSpots app (Cashu is app-only). +

+ )} +
+ {filtered.map((mint) => ( + handleAdd(mint.url)} + /> + ))} +
+ {filtered.length === 0 && ( +

+ No mints match “{query}”. +

+ )} +

+ Health data from the community MintRadar project — unofficial and may be + stale. Always review a mint's terms before trusting it with funds. +

+ + )} +
+
+ ); +} diff --git a/vite.config.ts b/vite.config.ts index 90341e5..d2e4113 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,14 @@ export default defineConfig(() => ({ server: { host: "::", port: 8080, + // Dev proxy for the MintRadar directory (no CORS upstream; see public/_redirects). + proxy: { + '/api/mints': { + target: 'https://mintradar.org', + changeOrigin: true, + secure: true, + }, + }, }, plugins: [ react(),