diff --git a/packages/ui/README.md b/packages/ui/README.md index c552e27..3fca909 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,255 +1,205 @@ -# Stellar Explain โ€“ Core API +# Stellar Explain โ€” Frontend -The `core` package contains the Rust backend API for Stellar Explain. - -It exposes HTTP endpoints that fetch data from Horizon and return structured explanations of Stellar transactions. +The Next.js frontend for [Stellar Explain](https://github.com/StellarCommons/Stellar-Explain) โ€” a tool that explains Stellar blockchain transactions and accounts in plain English. --- -## ๐Ÿš€ Getting Started - -### Prerequisites - -- Rust (latest stable recommended) - Install via: https://rustup.rs -- Cargo (installed with Rust) -- Access to a Horizon instance (defaults to public/testnet) -- Optional: Docker (if running via container) +## Prerequisites -Check your Rust version: +- **Node.js** v18 or later +- **npm** v9 or later +- A running instance of the [Stellar Explain backend](../core/README.md) (Rust) +Check your versions: ```bash -rustc --version +node --version +npm --version ``` --- -## โš™๏ธ Environment Variables +## Setup -The API reads configuration from environment variables: +```bash +# 1. Clone the repo (if you haven't already) +git clone https://github.com/StellarCommons/Stellar-Explain.git +cd Stellar-Explain/packages/ui -| Variable | Description | Default | -| ----------------- | --------------------------- | ----------------------- | -| `STELLAR_NETWORK` | `public` or `testnet` | `public` | -| `HORIZON_URL` | Custom Horizon URL override | Network default | -| `CORS_ORIGIN` | Allowed CORS origin | `http://localhost:3000` | -| `RUST_LOG` | Logging level | `info` | +# 2. Install dependencies +npm install -Example: +# 3. Configure environment variables +cp .env.local.example .env.local +# Edit .env.local and set API_URL to your running backend -```bash -export STELLAR_NETWORK=testnet -export RUST_LOG=info +# 4. Start the dev server +npm run dev ``` ---- +The app will be available at `http://localhost:3000`. -## ๐Ÿ›  Build & Run +--- -From the repository root: +## Environment Variables -```bash -cargo build --release -p core -``` +| Variable | Required | Description | Example | +|----------|----------|-------------|---------| +| `API_URL` | โœ… Yes | URL of the Stellar Explain Rust backend | `http://localhost:4000` | +| `NEXT_PUBLIC_STELLAR_NETWORK` | No | Network label shown in the UI | `testnet` | -Run locally: +> **Note:** `API_URL` is read server-side only and is never exposed to the browser. All requests to the backend are proxied through Next.js API routes. -```bash -cargo run -p core -``` +--- -The server starts on: +## Scripts -``` -http://localhost:4000 -``` +| Command | Description | +|---------|-------------| +| `npm run dev` | Start development server with Turbopack | +| `npm run build` | Build for production | +| `npm run start` | Start production server | +| `npm run lint` | Run ESLint + TypeScript type check | +| `npm run format` | Format all source files with Prettier | +| `npm run format:check` | Check formatting without writing changes | --- -## ๐Ÿงช Running Tests +## Project Structure -Run all tests: - -```bash -cargo test -p core ``` - -Run with logs: - -```bash -RUST_LOG=debug cargo test -p core +packages/ui/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ app/ # Next.js App Router pages +โ”‚ โ”‚ โ”œโ”€โ”€ page.tsx # Landing page (/) +โ”‚ โ”‚ โ”œโ”€โ”€ app/page.tsx # Search page (/app) +โ”‚ โ”‚ โ”œโ”€โ”€ tx/[hash]/page.tsx # Transaction result (/tx/:hash) +โ”‚ โ”‚ โ”œโ”€โ”€ account/[address]/ # Account result (/account/:address) +โ”‚ โ”‚ โ””โ”€โ”€ api/ # Next.js proxy routes (server-side only) +โ”‚ โ”‚ โ”œโ”€โ”€ tx/[hash]/route.ts +โ”‚ โ”‚ โ”œโ”€โ”€ account/[address]/route.ts +โ”‚ โ”‚ โ””โ”€โ”€ health/route.ts +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ components/ # Shared UI components +โ”‚ โ”‚ โ”œโ”€โ”€ AppShell.tsx # Shared page wrapper with header and context +โ”‚ โ”‚ โ”œโ”€โ”€ AppShellContext.ts # React context + useAppShell() hook +โ”‚ โ”‚ โ”œโ”€โ”€ TransactionResult.tsx # Transaction explanation display +โ”‚ โ”‚ โ”œโ”€โ”€ AccountResult.tsx # Account explanation display +โ”‚ โ”‚ โ”œโ”€โ”€ ErrorDisplay.tsx # Typed API error display +โ”‚ โ”‚ โ”œโ”€โ”€ Toast.tsx # Copy-to-clipboard notification +โ”‚ โ”‚ โ”œโ”€โ”€ Card.tsx # Base card wrapper +โ”‚ โ”‚ โ”œโ”€โ”€ Pill.tsx # Status pill (success/fail/warning) +โ”‚ โ”‚ โ”œโ”€โ”€ Label.tsx # Section label +โ”‚ โ”‚ โ”œโ”€โ”€ AddressChip.tsx # Truncated address with copy +โ”‚ โ”‚ โ”œโ”€โ”€ TabSwitcher.tsx # Transaction / Account tab toggle +โ”‚ โ”‚ โ”œโ”€โ”€ SearchBar.tsx # Search input + submit +โ”‚ โ”‚ โ”œโ”€โ”€ landing/ # Landing page sections +โ”‚ โ”‚ โ”œโ”€โ”€ history/ # Search history panel (UI #21) +โ”‚ โ”‚ โ””โ”€โ”€ addressbook/ # Address book panel (UI #22) +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ hooks/ # Custom React hooks +โ”‚ โ”‚ โ”œโ”€โ”€ useSearchHistory.ts # localStorage search history +โ”‚ โ”‚ โ”œโ”€โ”€ useAddressBook.ts # localStorage address book +โ”‚ โ”‚ โ”œโ”€โ”€ useCopyToClipboard.ts # Clipboard with reset state +โ”‚ โ”‚ โ””โ”€โ”€ usePersonalMode.ts # Personalised explanation mode +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ lib/ # Utilities and API client +โ”‚ โ”‚ โ”œโ”€โ”€ api.ts # Typed fetch functions +โ”‚ โ”‚ โ”œโ”€โ”€ errors.ts # Error type guards and helpers +โ”‚ โ”‚ โ””โ”€โ”€ utils.ts # Formatting helpers +โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ types/ +โ”‚ โ””โ”€โ”€ index.ts # TypeScript types mirroring backend shapes +โ”‚ +โ”œโ”€โ”€ .env.local.example # Environment variable template +โ”œโ”€โ”€ next.config.ts # Next.js configuration (standalone output) +โ”œโ”€โ”€ tailwind.config.ts # Tailwind CSS configuration +โ”œโ”€โ”€ tsconfig.json # TypeScript configuration (strict mode) +โ”œโ”€โ”€ .prettierrc # Prettier formatting rules +โ”œโ”€โ”€ eslint.config.mjs # ESLint configuration +โ””โ”€โ”€ Dockerfile # Multi-stage Docker build ``` --- -## ๐Ÿณ Running with Docker +## Connecting to the Backend -From the project root: +### Option A โ€” Run the Rust backend locally ```bash -docker-compose up --build +# From the monorepo root +cargo run -p core +# Backend starts at http://localhost:4000 ``` -The API will be available at: - +Then set in your `.env.local`: ``` -http://localhost:4000 +API_URL=http://localhost:4000 ``` -Health check: +### Option B โ€” Docker Compose (recommended) -```bash -curl http://localhost:4000/health -``` - ---- - -## ๐Ÿ“ก API Endpoints - ---- - -### GET `/health` - -Returns service status and Horizon connectivity. - -#### Example +Runs both frontend and backend together: ```bash -curl http://localhost:4000/health -``` - -#### Response - -```json -{ - "status": "ok", - "network": "testnet", - "horizon_reachable": true, - "version": "0.1.0" -} +# From the monorepo root +docker compose up --build ``` -If Horizon is unreachable: +- Frontend: `http://localhost:3000` +- Backend: `http://localhost:4000` -- HTTP 503 -- `"status": "degraded"` +The frontend container connects to the backend via Docker's internal network using `http://backend:4000` โ€” this is configured automatically in `docker-compose.yml`. --- -### GET `/tx/:hash` +## Architecture -Returns a structured explanation of a Stellar transaction. +### Proxy Pattern -#### Example +The frontend never calls the Stellar Explain backend directly from the browser. All API calls go through Next.js server-side proxy routes: -```bash -curl http://localhost:4000/tx/3b7e9b6f... ``` - -#### Example Response - -```json -{ - "hash": "3b7e9b6f...", - "successful": true, - "fee_charged": 100, - "memo": "Payment for services", - "operations": [ - { - "type": "payment", - "from": "GABC...", - "to": "GXYZ...", - "asset": "XLM", - "amount": "100.0000000" - } - ], - "explanation": "This transaction transferred 100 XLM from GABC... to GXYZ..." -} +Browser โ†’ /api/tx/[hash] (Next.js server) โ†’ http://localhost:4000/tx/:hash (Rust backend) ``` ---- - -### GET `/account/:address` (Planned) +This means `API_URL` is only ever read on the server โ€” it is never bundled into the client JavaScript or exposed in network requests. This is intentional. -Will return paginated transactions for an account. +### AppShell + Context -#### Example +All app pages (`/app`, `/tx/:hash`, `/account/:address`) are wrapped in `AppShell`, which provides shared state via React context. Child pages access shared state via `useAppShell()`: -```bash -curl http://localhost:4000/account/GABC... -``` - -#### Expected Response Structure +```tsx +function TxPageInner() { + const { addEntry } = useAppShell(); + ... +} -```json -{ - "account": "GABC...", - "transactions": [], - "next_cursor": "...", - "prev_cursor": "..." +export default function TxPage() { + return ( + + + + ); } ``` ---- - -## ๐Ÿ” Rate Limiting - -The API applies global per-IP rate limiting: - -- 60 requests per minute -- Returns HTTP 429 if exceeded -- Includes `Retry-After` header - ---- - -## ๐Ÿ— Project Structure +### Route Structure -``` -packages/core -โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ routes/ -โ”‚ โ”œโ”€โ”€ services/ -โ”‚ โ”œโ”€โ”€ models/ -โ”‚ โ”œโ”€โ”€ config/ -โ”‚ โ””โ”€โ”€ main.rs -โ”œโ”€โ”€ Cargo.toml -โ””โ”€โ”€ README.md -``` +| URL | Description | +|-----|-------------| +| `/` | Landing page | +| `/app` | Search page โ€” enter a hash or address | +| `/tx/:hash` | Transaction explanation result | +| `/account/:address` | Account explanation result | --- -## ๐Ÿค Contributing +## Contributing -We welcome contributions. - -### How to Contribute - -1. Check open issues -2. Create a new branch from `main` -3. Follow conventional commit format -4. Ensure: - - Code compiles - - Tests pass - - Formatting is clean (`cargo fmt`) - - Linting passes (`cargo clippy`) -5. Open a Pull Request referencing the issue number - -Example: - -``` -feat(health): add horizon connectivity check -``` - -### Running Checks Before PR +See the root [CONTRIBUTING.md](../../CONTRIBUTING.md) for contribution guidelines. +Before opening a PR, make sure: ```bash -cargo fmt -cargo clippy -cargo test -``` - ---- - -## ๐Ÿ“œ License - -MIT (or project-specific license) +npm run lint # No TypeScript or ESLint errors +npm run format:check # Code is formatted correctly +``` \ No newline at end of file diff --git a/packages/ui/src/app/account/[address]/page.tsx b/packages/ui/src/app/account/[address]/page.tsx index 1806aaf..5fff880 100644 --- a/packages/ui/src/app/account/[address]/page.tsx +++ b/packages/ui/src/app/account/[address]/page.tsx @@ -1,76 +1,65 @@ -"use client"; +'use client'; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { fetchAccount, getErrorMessage } from "@/lib/api"; -import type { AccountExplanation } from "@/types"; -import { AccountResult } from "@/components/AccountResult"; -import AppShell from "@/components/AppShell"; -import { useAppShell } from "@/components/AppShellContext"; - -// โ”€โ”€ Inner page โ€” consumes context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { fetchAccount } from '@/lib/api'; +import type { AccountExplanation } from '@/types'; +import { AccountResult } from '@/components/AccountResult'; +import ErrorDisplay from '@/components/ErrorDisplay'; +import AppShell from '@/components/AppShell'; +import { useAppShell } from '@/components/AppShellContext'; function AccountPageInner() { const { address } = useParams<{ address: string }>(); const router = useRouter(); - const { addEntry, isSaved, getEntry, saveAddress, removeAddress } = - useAppShell(); + const { addEntry, isSaved, getEntry, saveAddress, removeAddress } = useAppShell(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); - useEffect(() => { + const load = useCallback(async () => { if (!address) return; - let cancelled = false; - - async function load() { - setLoading(true); - setError(null); - try { - const result = await fetchAccount(address); - if (!cancelled) { - setData(result); - addEntry("account", address, result.summary); - } - } catch (err) { - if (!cancelled) setError(getErrorMessage(err)); - } finally { - if (!cancelled) setLoading(false); - } + setLoading(true); + setError(null); + try { + const result = await fetchAccount(address); + setData(result); + addEntry('account', address, result.summary); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); } + }, [address, addEntry]); + useEffect(() => { load(); - return () => { - cancelled = true; - }; - }, [address, addEntry]); + }, [load]); return ( -
+
{/* Back button */} {loading && } - - {error && !loading && ( -
- {error} -
- )} + {error && !loading && } {data && !loading && ( @@ -120,62 +102,30 @@ export default function AccountPage() { ); } -// โ”€โ”€ Skeleton โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - function AccountSkeleton() { return (
-
-
+
+
-
+
); } diff --git a/packages/ui/src/app/layout.tsx b/packages/ui/src/app/layout.tsx index 1508c38..d881047 100644 --- a/packages/ui/src/app/layout.tsx +++ b/packages/ui/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"; import "./globals.css"; +import NetworkStatusBanner from "@/components/NetworkStatusBanner"; // import NetworkStatusBanner from "@/components/NetworkStatusBanner"; const ibmPlexMono = IBM_Plex_Mono({ @@ -32,7 +33,7 @@ export default function RootLayout({ suppressHydrationWarning > {" "} - {/* */} + {children} diff --git a/packages/ui/src/app/tx/[hash]/page.tsx b/packages/ui/src/app/tx/[hash]/page.tsx index 7f85a21..1628b8a 100644 --- a/packages/ui/src/app/tx/[hash]/page.tsx +++ b/packages/ui/src/app/tx/[hash]/page.tsx @@ -1,15 +1,14 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useParams, useRouter } from "next/navigation"; -import { fetchTransaction, getErrorMessage } from "@/lib/api"; +import { fetchTransaction } from "@/lib/api"; import type { TransactionExplanation } from "@/types"; import { TransactionResult } from "@/components/TransactionResult"; +import ErrorDisplay from "@/components/ErrorDisplay"; import AppShell from "@/components/AppShell"; import { useAppShell } from "@/components/AppShellContext"; -// โ”€โ”€ Inner page โ€” consumes context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - function TxPageInner() { const { hash } = useParams<{ hash: string }>(); const router = useRouter(); @@ -17,33 +16,26 @@ function TxPageInner() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); - useEffect(() => { + const load = useCallback(async () => { if (!hash) return; - let cancelled = false; - - async function load() { - setLoading(true); - setError(null); - try { - const result = await fetchTransaction(hash); - if (!cancelled) { - setData(result); - addEntry("transaction", hash, result.summary); - } - } catch (err) { - if (!cancelled) setError(getErrorMessage(err)); - } finally { - if (!cancelled) setLoading(false); - } + setLoading(true); + setError(null); + try { + const result = await fetchTransaction(hash); + setData(result); + addEntry("transaction", hash, result.summary); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); } + }, [hash, addEntry]); + useEffect(() => { load(); - return () => { - cancelled = true; - }; - }, [hash, addEntry]); + }, [load]); return (
@@ -64,42 +56,31 @@ function TxPageInner() { transition: "color 0.15s ease", }} onMouseEnter={(e) => { - (e.currentTarget as HTMLButtonElement).style.color = - "rgba(255,255,255,0.7)"; + (e.currentTarget as HTMLButtonElement).style.color = "rgba(255,255,255,0.7)"; }} onMouseLeave={(e) => { - (e.currentTarget as HTMLButtonElement).style.color = - "rgba(255,255,255,0.3)"; + (e.currentTarget as HTMLButtonElement).style.color = "rgba(255,255,255,0.3)"; }} > - + Back to search {loading && } - {error && !loading && ( -
- {error} -
+ )} - {data && !loading && } + {data && !loading && ( + + )}
); } -// โ”€โ”€ Page โ€” wraps with AppShell โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - export default function TxPage() { return ( @@ -108,51 +89,16 @@ export default function TxPage() { ); } -// โ”€โ”€ Skeleton โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - function TransactionSkeleton() { return (
-
-
-
-
-
-
+
+
+
+
+
+
); -} +} \ No newline at end of file diff --git a/packages/ui/src/components/ErrorDisplay.tsx b/packages/ui/src/components/ErrorDisplay.tsx new file mode 100644 index 0000000..d18bb9f --- /dev/null +++ b/packages/ui/src/components/ErrorDisplay.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { isApiError } from "@/lib/errors"; +import type { ApiError } from "@/types"; + +interface Props { + error: ApiError | Error | unknown; + identifier?: string; // hash or address โ€” used in NOT_FOUND message + onRetry?: () => void; +} + +export default function ErrorDisplay({ error, identifier, onRetry }: Props) { + const { title, message, showRetry } = resolveError(error, identifier); + + return ( +
+ {/* Icon + title row */} +
+ + + + + +

+ {title} +

+
+ + {/* Message */} +

+ {message} +

+ + {/* Retry button */} + {showRetry && onRetry && ( + + )} +
+ ); +} + +// โ”€โ”€ Error resolution logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface Resolved { + title: string; + message: string; + showRetry: boolean; +} + +function resolveError(error: unknown, identifier?: string): Resolved { + if (isApiError(error)) { + const message = error.error.message; + switch (error.error.code) { + case "NOT_FOUND": + return { + title: "NOT FOUND", + message: identifier + ? `"${truncate(identifier)}" was not found on the Stellar network. Check the value and try again.` + : "This was not found on the Stellar network. Check the value and try again.", + showRetry: false, + }; + case "UPSTREAM_ERROR": + return { + title: "NETWORK ERROR", + message: "Stellar network is temporarily unreachable โ€” please try again in a moment.", + showRetry: true, + }; + case "BAD_REQUEST": + return { + title: "INVALID REQUEST", + message: message ?? "Bad request โ€” please check your input.", + showRetry: false, + }; + default: + return { + title: "ERROR", + message: message ?? "Something went wrong. Please try again.", + showRetry: true, + }; + } + } + + if (error instanceof Error) { + return { + title: "ERROR", + message: error.message || "Something went wrong. Please try again.", + showRetry: true, + }; + } + + return { + title: "ERROR", + message: "Something went wrong. Please try again.", + showRetry: true, + }; +} + +function truncate(value: string): string { + if (value.length <= 16) return value; + return `${value.slice(0, 8)}โ€ฆ${value.slice(-8)}`; +} \ No newline at end of file diff --git a/packages/ui/src/components/NetworkStatusBanner.tsx b/packages/ui/src/components/NetworkStatusBanner.tsx new file mode 100644 index 0000000..cd87c28 --- /dev/null +++ b/packages/ui/src/components/NetworkStatusBanner.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { fetchHealth } from "@/lib/api"; + +const POLL_INTERVAL = 60_000; // 60 seconds + +export default function NetworkStatusBanner() { + const [degraded, setDegraded] = useState(false); + const [dismissed, setDismissed] = useState(false); + + const check = useCallback(async () => { + try { + const health = await fetchHealth(); + const isOk = health.status === "ok" && health.horizon_reachable; + // const isOk = false; + setDegraded(!isOk); + // If network recovered, un-dismiss so banner can show again if it degrades later + if (isOk) setDismissed(false); + } catch { + // fetch failed entirely โ€” treat as degraded + setDegraded(true); + } + }, []); + + useEffect(() => { + // Run immediately on mount, non-blocking + check(); + const interval = setInterval(check, POLL_INTERVAL); + return () => clearInterval(interval); + }, [check]); + + if (!degraded || dismissed) return null; + + return ( +
+
+ {/* Warning icon */} + + + + + + +

+ Stellar network connectivity is degraded. + {" "}Explanations may be unavailable or slower than usual. +

+
+ + {/* Dismiss button */} + +
+ ); +} \ No newline at end of file diff --git a/packages/ui/src/lib/env.ts b/packages/ui/src/lib/env.ts new file mode 100644 index 0000000..3c21b98 --- /dev/null +++ b/packages/ui/src/lib/env.ts @@ -0,0 +1,53 @@ +/** + * Environment variable validation. + * + * Import this module anywhere env vars are needed. + * In development: throws a clear error if required vars are missing. + * In production: logs a warning and falls back to safe defaults. + */ + +const isDev = process.env.NODE_ENV === 'development'; + +function requireEnv(key: string, fallback: string): string { + const value = process.env[key]; + + if (!value) { + const message = [ + `[Stellar Explain] Missing environment variable: ${key}`, + ` Copy .env.local.example to .env.local and set ${key}.`, + ` Falling back to: "${fallback}"`, + ].join('\n'); + + if (isDev) { + throw new Error(message); + } else { + console.warn(message); + return fallback; + } + } + + return value; +} + +function optionalEnv(key: string, fallback: string): string { + return process.env[key] ?? fallback; +} + +// โ”€โ”€ Validated variables โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * The base URL of the Rust backend. + * Server-side only โ€” never exposed to the browser. + * Set via API_URL in .env.local. + */ +export const API_URL = requireEnv('API_URL', 'http://localhost:4000'); + +/** + * The active Stellar network. + * Used for display purposes in the UI (e.g. network badge in Navbar). + * Must be 'mainnet' or 'testnet'. + */ +export const STELLAR_NETWORK = optionalEnv( + 'NEXT_PUBLIC_STELLAR_NETWORK', + 'testnet', +) as 'mainnet' | 'testnet'; \ No newline at end of file