From 23527754065d38c5948ac3e82bc913e6e7a53293 Mon Sep 17 00:00:00 2001 From: Memplethee-lab Date: Sat, 17 Oct 2026 21:37:18 +0100 Subject: [PATCH] Fix platform basics: env config, money formatting, providers, components page (Memplethee-lab) - Document required env vars in .env.example and validate NEXT_PUBLIC_API_URL once in lib/env.ts, defaulting to http://localhost:6000 in development and aligning CI and README with the same port (Closes #1985) - Add shared fromMinorUnits/minorUnitDecimals helpers in lib/utils.ts and use them on the usage, payments, credits and admin money surfaces so amounts use the currency's real exponent including 7-decimal XLM (Closes #1984) - Consolidate to a single Providers component (theme + query client + toaster), skip retries and surface an expiry toast for 401/403 errors, and render React Query devtools only in development (Closes #1980) - Gate the /components design-review page behind non-production via notFound() and disallow it in robots.txt (Closes #1979) Coordination notes for reviewers: - Session-clearing/redirect on 401 itself lands with the 401-handling PR (#1977 by ykargeee-bit); this PR only wires retry-skip + toast into the one query client so the two PRs do not edit the same provider files twice. - lib/env.ts intentionally does NOT touch the API modules' auth headers; the cookie-session refactor (#1975/#1976) cannot merge conflicts with this PR. --- .github/workflows/CI.yaml | 4 +- frontend/.env.example | 59 ++++++++++++++ frontend/.gitignore | 1 + frontend/README.md | 16 ++++ frontend/app/__tests__/robots.test.ts | 36 +++++++++ frontend/app/admin/charge/page.tsx | 3 +- frontend/app/admin/layout.tsx | 6 +- frontend/app/admin/payments/page.tsx | 8 +- frontend/app/admin/refund/page.tsx | 3 +- .../app/components/__tests__/page.test.tsx | 36 +++++++++ frontend/app/components/page.tsx | 6 ++ frontend/app/credits/page.tsx | 15 ++-- frontend/app/payments/[id]/page.tsx | 4 +- frontend/app/payments/page.tsx | 4 +- frontend/app/payments/return/page.tsx | 6 +- frontend/app/robots.ts | 2 +- frontend/app/usage/page.tsx | 5 +- .../components/wallet/wallet-status-card.tsx | 35 ++------- frontend/lib/__tests__/env.test.ts | 57 ++++++++++++++ frontend/lib/__tests__/providers.test.tsx | 32 ++++++++ frontend/lib/__tests__/utils.test.ts | 55 ++++++++++++- frontend/lib/admin-api.ts | 5 +- frontend/lib/auth-api.ts | 5 +- frontend/lib/credits-api.ts | 6 +- frontend/lib/env.ts | 77 +++++++++++++++++++ frontend/lib/payments-api.ts | 4 +- frontend/lib/providers.tsx | 26 ------- frontend/lib/providers/providers.tsx | 50 +++++++++++- frontend/lib/query-provider.tsx | 26 ------- frontend/lib/utils.ts | 38 +++++++++ frontend/lib/wallet-api.ts | 5 +- 31 files changed, 516 insertions(+), 119 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/app/__tests__/robots.test.ts create mode 100644 frontend/app/components/__tests__/page.test.tsx create mode 100644 frontend/lib/__tests__/env.test.ts create mode 100644 frontend/lib/__tests__/providers.test.tsx create mode 100644 frontend/lib/env.ts delete mode 100644 frontend/lib/providers.tsx delete mode 100644 frontend/lib/query-provider.tsx diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index dbed732f..e9ab5d9c 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -135,7 +135,7 @@ jobs: - name: Build run: npm run build env: - NEXT_PUBLIC_API_URL: http://localhost:3001 + NEXT_PUBLIC_API_URL: http://localhost:6000 e2e: name: Frontend E2E (Playwright) @@ -165,7 +165,7 @@ jobs: - name: Run E2E Tests run: npm run test:e2e env: - NEXT_PUBLIC_API_URL: http://localhost:3001 + NEXT_PUBLIC_API_URL: http://localhost:6000 - name: Upload Playwright Artifacts on Failure if: failure() diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..99b7f5b5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,59 @@ +# Frontend environment variables +# +# Copy this file to .env.local for local development: +# cp frontend/.env.example frontend/.env.local +# +# Variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle at +# build time, so changing one requires a rebuild. All values here are examples. + +# --------------------------------------------------------------------------- +# API base URL (required) +# --------------------------------------------------------------------------- +# Base URL of the ManageHub backend API. Every frontend API module +# (lib/auth-api.ts, lib/admin-api.ts, lib/payments-api.ts, lib/wallet-api.ts, +# lib/credits-api.ts) builds its request URLs from this value. +# +# The default below matches the backend's configured port (PORT=6000 in +# backend/.env.example). Unset in development the app falls back to +# http://localhost:6000 (see lib/env.ts). Production builds MUST set this +# variable explicitly; there is no production default. +NEXT_PUBLIC_API_URL=http://localhost:6000 + +# --------------------------------------------------------------------------- +# Public app origin +# --------------------------------------------------------------------------- +# Canonical origin of this frontend, used for metadataBase (app/layout.tsx), +# robots.txt (app/robots.ts) and the sitemap (app/sitemap.ts). Defaults to +# https://managehub.app when unset. +NEXT_PUBLIC_APP_URL=https://managehub.app + +# --------------------------------------------------------------------------- +# Sentry +# --------------------------------------------------------------------------- +# Error and performance monitoring. The DSN enables Sentry; without it the +# SDK stays inert. See SENTRY_SETUP.md for the full walkthrough. +NEXT_PUBLIC_SENTRY_DSN= +# Environment label reported to Sentry; defaults to NODE_ENV when unset. +NEXT_PUBLIC_SENTRY_ENVIRONMENT= +# Sample rates between 0 and 1 (defaults live in sentry.client.config.ts). +NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE= +NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE= +NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE= + +# --------------------------------------------------------------------------- +# Search engine verification +# --------------------------------------------------------------------------- +# Google site verification meta tag rendered by app/layout.tsx metadata. +NEXT_PUBLIC_GOOGLE_VERIFICATION= + +# --------------------------------------------------------------------------- +# Server-only variables +# --------------------------------------------------------------------------- +# Secret used by middleware.ts to verify the session cookie's JWT signature +# with jose. Keep this aligned with the backend's JWT_SECRET. Never expose +# this variable under the NEXT_PUBLIC_ prefix. +JWT_SECRET= + +# Set to true to opt into the Next bundle analyzer with: +# npm run build:analyze +ANALYZE=false diff --git a/frontend/.gitignore b/frontend/.gitignore index d6f121fb..4482c512 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -36,6 +36,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/frontend/README.md b/frontend/README.md index 488c88ad..2c32eec4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -17,6 +17,22 @@ bun dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Configuration + +Copy `frontend/.env.example` to `frontend/.env.local` and adjust the values +for your environment. Documented variables include: + +- `NEXT_PUBLIC_API_URL` - base URL of the backend API. In development the app + defaults to `http://localhost:6000` (the backend's configured port); in + production this variable is **required** and the build fails fast at request + time when it is missing. +- `NEXT_PUBLIC_APP_URL` - canonical origin of this frontend (defaults to + `https://managehub.app`). +- `NEXT_PUBLIC_SENTRY_*`, `NEXT_PUBLIC_GOOGLE_VERIFICATION` - optional + observability and search-console settings. +- `JWT_SECRET` (server-only) - used by `middleware.ts` to verify the session + cookie. + ## Frontend foundations - Visit `/components` for the shared component catalog. diff --git a/frontend/app/__tests__/robots.test.ts b/frontend/app/__tests__/robots.test.ts new file mode 100644 index 00000000..926df030 --- /dev/null +++ b/frontend/app/__tests__/robots.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import robots from "../robots"; + +// robots() declares MetadataRoute.Robots whose `rules` may be a single rule, +// an array of rules, or even a plain path string; normalise before checking. +function disallowedPaths(): string[] { + const { rules } = robots(); + const ruleList = Array.isArray(rules) ? rules : [rules]; + return ruleList.flatMap((rule) => { + if (typeof rule === "string") return []; + if (rule.disallow === undefined) return []; + return Array.isArray(rule.disallow) ? rule.disallow : [rule.disallow]; + }); +} + +describe("robots", () => { + it("disallows the admin area and the component catalog", () => { + const disallow = disallowedPaths(); + + expect(disallow).toContain("/admin/"); + expect(disallow).toContain("/components/"); + }); + + it("allows the rest of the site for everyone", () => { + const { rules } = robots(); + const first = Array.isArray(rules) ? rules[0] : rules; + + expect(typeof first !== "string" && first.userAgent).toBe("*"); + expect(typeof first !== "string" && first.allow).toBe("/"); + }); + + it("publishes the sitemap on the configured base URL", () => { + expect(robots().sitemap).toContain("https://managehub.app/sitemap.xml"); + }); +}); diff --git a/frontend/app/admin/charge/page.tsx b/frontend/app/admin/charge/page.tsx index 3c75b425..f46a1e63 100644 --- a/frontend/app/admin/charge/page.tsx +++ b/frontend/app/admin/charge/page.tsx @@ -9,6 +9,7 @@ import { } from "@/components/app-ui"; import { RequireAdmin } from "@/components/admin/require-admin"; import { chargeCredits, type ChargeCreditsResponse } from "@/lib/payments-api"; +import { formatCurrency, fromMinorUnits } from "@/lib/utils"; import Cookies from "js-cookie"; import { useState } from "react"; import { toast } from "sonner"; @@ -114,7 +115,7 @@ export default function AdminChargePage() {

{result.posted ? "Posted" : "Already applied (not posted)"} — new - balance {result.balanceAfter} {result.currency} + balance {formatCurrency(fromMinorUnits(result.balanceAfter, result.currency), result.currency)}

Transaction {result.transaction.id} ({result.transaction.kind}) diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index dca57495..4aee2187 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -8,7 +8,7 @@ import { LayoutDashboard, Coins, } from "lucide-react"; -import { AdminProviders } from "@/lib/providers"; +import { Providers } from "@/lib/providers/providers"; import { AdminBreadcrumbs } from "@/components/admin/admin-breadcrumbs"; import { cn } from "@/lib/utils"; @@ -31,7 +31,7 @@ export default function AdminLayout({ children: React.ReactNode; }) { return ( - +

@@ -60,7 +60,7 @@ export default function AdminLayout({
- + ); } diff --git a/frontend/app/admin/payments/page.tsx b/frontend/app/admin/payments/page.tsx index 2255a467..ba312a96 100644 --- a/frontend/app/admin/payments/page.tsx +++ b/frontend/app/admin/payments/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { ClipboardList, ExternalLink } from "lucide-react"; import { listManualReviewPayments } from "@/lib/payments-api"; import type { Payment } from "@/lib/payments-api"; +import { formatCurrency, fromMinorUnits } from "@/lib/utils"; import { Card, CardHeader, @@ -14,6 +15,11 @@ import { } from "@/components/admin/ui"; import { RequireAdmin, useAdminToken } from "@/components/admin/require-admin"; +// Payment amounts arrive as integer minor units. +function formatAmount(amount: number, currency: string): string { + return formatCurrency(fromMinorUnits(amount, currency), currency); +} + const PAGE_SIZE = 25; export default function AdminManualReviewPage() { @@ -99,7 +105,7 @@ function ManualReviewInner({ token }: { token: string }) { - {p.amount.toLocaleString()} {p.currency} + {formatAmount(p.amount, p.currency)} {p.manualReviewReason ?? "—"} diff --git a/frontend/app/admin/refund/page.tsx b/frontend/app/admin/refund/page.tsx index 39c985b2..28642e9d 100644 --- a/frontend/app/admin/refund/page.tsx +++ b/frontend/app/admin/refund/page.tsx @@ -18,6 +18,7 @@ import Cookies from "js-cookie"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; +import { formatCurrency, fromMinorUnits } from "@/lib/utils"; function PaymentRow({ payment }: { payment: Payment }) { const queryClient = useQueryClient(); @@ -50,7 +51,7 @@ function PaymentRow({ payment }: { payment: Payment }) { {payment.id}

- {payment.currency} {payment.amount} ·{" "} + {formatCurrency(fromMinorUnits(payment.amount, payment.currency), payment.currency)} ·{" "} {new Date(payment.createdAt).toLocaleString()}

diff --git a/frontend/app/components/__tests__/page.test.tsx b/frontend/app/components/__tests__/page.test.tsx new file mode 100644 index 00000000..3f504c7c --- /dev/null +++ b/frontend/app/components/__tests__/page.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mirror Next's own notFound() behaviour: throw an error whose digest marks +// the 404 HTTP fallback. +vi.mock("next/navigation", () => ({ + notFound: () => { + const error = new Error("NEXT_HTTP_ERROR_FALLBACK;404") as Error & { + digest?: string; + }; + error.digest = "NEXT_HTTP_ERROR_FALLBACK;404"; + throw error; + }, +})); + +import ComponentsPage from "../page"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("ComponentsPage", () => { + it("404s in production builds", () => { + vi.stubEnv("NODE_ENV", "production"); + + expect(() => ComponentsPage()).toThrow(/404/); + }); + + it("renders the catalog for local development", () => { + vi.stubEnv("NODE_ENV", "development"); + + render(); + + expect(screen.getByText(/Component catalog/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/app/components/page.tsx b/frontend/app/components/page.tsx index 7ac97302..5b20541b 100644 --- a/frontend/app/components/page.tsx +++ b/frontend/app/components/page.tsx @@ -1,8 +1,14 @@ +import { notFound } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; export default function ComponentsPage() { + // The component catalog is an internal design-review surface; it must not + // ship in production builds. 404 outside development and keep it out of + // robots.txt (app/robots.ts). + if (process.env.NODE_ENV === "production") notFound(); + return (
diff --git a/frontend/app/credits/page.tsx b/frontend/app/credits/page.tsx index 50066e8a..f6f99735 100644 --- a/frontend/app/credits/page.tsx +++ b/frontend/app/credits/page.tsx @@ -6,6 +6,12 @@ import { Wallet, ArrowDownLeft, ArrowUpRight, ChevronLeft, ChevronRight } from " import Cookies from "js-cookie"; import { getCreditBalance, getCreditStatement } from "@/lib/credits-api"; import type { CreditStatementEntry } from "@/lib/credits-api"; +import { formatCurrency, fromMinorUnits } from "@/lib/utils"; + +// Credit balances and statement amounts arrive as integer minor units. +function formatMinorAmount(amount: number, currency: string): string { + return formatCurrency(fromMinorUnits(amount, currency), currency); +} const PAGE_SIZE = 20; @@ -68,7 +74,7 @@ function CreditsInner({ token }: { token: string }) { ) : ( <>

- {balance.data!.balance.toLocaleString(undefined, { + {fromMinorUnits(balance.data!.balance, balance.data!.currency).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })} @@ -78,7 +84,7 @@ function CreditsInner({ token }: { token: string }) {

{balance.data!.overdraftLimit > 0 && (

- Overdraft limit: {balance.data!.overdraftLimit.toLocaleString()} {balance.data!.currency} + Overdraft limit: {formatMinorAmount(balance.data!.overdraftLimit, balance.data!.currency)}

)} @@ -143,10 +149,7 @@ function CreditsInner({ token }: { token: string }) { }`} > {entry.direction === "CREDIT" ? "+" : "−"} - {entry.amount.toLocaleString(undefined, { - minimumFractionDigits: 2, - })}{" "} - {entry.currency} + {formatMinorAmount(entry.amount, entry.currency)}

))} diff --git a/frontend/app/payments/[id]/page.tsx b/frontend/app/payments/[id]/page.tsx index 588535ed..a5af3b16 100644 --- a/frontend/app/payments/[id]/page.tsx +++ b/frontend/app/payments/[id]/page.tsx @@ -7,9 +7,11 @@ import { getPayment } from "@/lib/payments-api"; import { useSessionStore } from "@/lib/stores/session-store"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { formatCurrency, fromMinorUnits } from "@/lib/utils"; +// Payment amounts arrive as integer minor units. function formatAmount(amount: number, currency: string): string { - return `${amount.toFixed(2)} ${currency}`; + return formatCurrency(fromMinorUnits(amount, currency), currency); } function statusTone(status: string): string { diff --git a/frontend/app/payments/page.tsx b/frontend/app/payments/page.tsx index ad9253f9..aca1251b 100644 --- a/frontend/app/payments/page.tsx +++ b/frontend/app/payments/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { getPayments } from "@/lib/payments-api"; import { useSessionStore } from "@/lib/stores/session-store"; +import { formatCurrency, fromMinorUnits } from "@/lib/utils"; import { Card, CardContent, @@ -12,8 +13,9 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +// Payment amounts arrive as integer minor units. function formatAmount(amount: number, currency: string): string { - return `${amount.toFixed(2)} ${currency}`; + return formatCurrency(fromMinorUnits(amount, currency), currency); } function statusTone(status: string): string { diff --git a/frontend/app/payments/return/page.tsx b/frontend/app/payments/return/page.tsx index e15d5f29..e6b7a81d 100644 --- a/frontend/app/payments/return/page.tsx +++ b/frontend/app/payments/return/page.tsx @@ -29,6 +29,7 @@ import { useSessionStore } from "@/lib/stores/session-store"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { getProviderReturnErrorMessage } from "@/lib/payment-return-errors"; +import { formatCurrency, fromMinorUnits } from "@/lib/utils"; // -- tuning constants --------------------------------------------------------- @@ -402,7 +403,10 @@ export default function PaymentReturnPage() {
Amount
- {payment.amount.toFixed(2)} {payment.currency} + {formatCurrency( + fromMinorUnits(payment.amount, payment.currency), + payment.currency, + )}
{payment.provider && ( diff --git a/frontend/app/robots.ts b/frontend/app/robots.ts index 33e7713e..63bdaad4 100644 --- a/frontend/app/robots.ts +++ b/frontend/app/robots.ts @@ -7,7 +7,7 @@ export default function robots(): MetadataRoute.Robots { rules: { userAgent: "*", allow: "/", - disallow: ["/admin/", "/wallet/", "/payments/", "/usage/", "/login/", "/register/"], + disallow: ["/admin/", "/wallet/", "/payments/", "/usage/", "/login/", "/register/", "/components/"], }, sitemap: `${baseUrl}/sitemap.xml`, }; diff --git a/frontend/app/usage/page.tsx b/frontend/app/usage/page.tsx index 2de74440..7a456144 100644 --- a/frontend/app/usage/page.tsx +++ b/frontend/app/usage/page.tsx @@ -2,12 +2,13 @@ import { Card, CardHeader, StatusBadge } from "@/components/app-ui"; import { listMyUsage, type MeteredUsageEvent } from "@/lib/payments-api"; -import { formatCurrency } from "@/lib/utils"; +import { formatCurrency, fromMinorUnits } from "@/lib/utils"; import Cookies from "js-cookie"; import { useQuery } from "@tanstack/react-query"; +// Usage event amounts (and per-unit prices) arrive as integer minor units. function formatMoney(amount: number, currency: string) { - return formatCurrency(amount / 100, currency || "USD"); + return formatCurrency(fromMinorUnits(amount, currency), currency || "USD"); } export default function UsagePage() { diff --git a/frontend/components/wallet/wallet-status-card.tsx b/frontend/components/wallet/wallet-status-card.tsx index d2da4137..763fb4ab 100644 --- a/frontend/components/wallet/wallet-status-card.tsx +++ b/frontend/components/wallet/wallet-status-card.tsx @@ -13,37 +13,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { WalletLinkForm } from "@/components/wallet/wallet-link-form"; import { useSessionStore } from "@/lib/stores/session-store"; +import { fromMinorUnits } from "@/lib/utils"; -/** - * Minor-unit decimal places per currency. `GET /credits/balance` is - * currency-scoped and not always XLM-denominated (`CREDITS_DEFAULT_CURRENCY` - * defaults to USD) -- the Stellar stroop ratio (10^7) only applies to XLM. - * Falls back to 2 (the ISO 4217 default) for any currency not listed here. - */ -const CURRENCY_MINOR_UNIT_DECIMALS: Record = { - XLM: 7, - // ISO 4217 zero-decimal currencies - JPY: 0, - KRW: 0, - VND: 0, - // ISO 4217 three-decimal currencies - BHD: 3, - JOD: 3, - KWD: 3, - OMR: 3, -}; -const DEFAULT_MINOR_UNIT_DECIMALS = 2; - -export function minorUnitDecimals(currency: string): number { - return ( - CURRENCY_MINOR_UNIT_DECIMALS[currency.toUpperCase()] ?? - DEFAULT_MINOR_UNIT_DECIMALS - ); -} - +// Minor-unit conversion (and the currency exponent table it uses) lives in +// lib/utils.ts so every money surface parses amounts the same way. The +// formatBalance contract tested by __tests__/format-balance.test.ts is +// unchanged: ".<2 digits> credit". export function formatBalance(minorUnits: number, currency: string): string { - const value = minorUnits / 10 ** minorUnitDecimals(currency); - return `${value.toFixed(2)} ${currency} credit`; + return `${fromMinorUnits(minorUnits, currency).toFixed(2)} ${currency} credit`; } /** diff --git a/frontend/lib/__tests__/env.test.ts b/frontend/lib/__tests__/env.test.ts new file mode 100644 index 00000000..6d3adeb2 --- /dev/null +++ b/frontend/lib/__tests__/env.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getAppBaseUrl, getApiBaseUrl } from "../env"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("getApiBaseUrl", () => { + it("falls back to the backend's port 6000 in development", () => { + vi.stubEnv("NODE_ENV", "development"); + delete process.env.NEXT_PUBLIC_API_URL; + + expect(getApiBaseUrl()).toBe("http://localhost:6000"); + }); + + it("honours an explicit value and strips the trailing slash", () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.managehub.io/"); + + expect(getApiBaseUrl()).toBe("https://api.managehub.io"); + }); + + it("throws a descriptive error when unset in production", () => { + vi.stubEnv("NODE_ENV", "production"); + delete process.env.NEXT_PUBLIC_API_URL; + + expect(() => getApiBaseUrl()).toThrow(/NEXT_PUBLIC_API_URL is not configured/); + }); + + it("keeps the legacy relative base outside development and production", () => { + vi.stubEnv("NODE_ENV", "test"); + delete process.env.NEXT_PUBLIC_API_URL; + + expect(getApiBaseUrl()).toBe(""); + }); +}); + +describe("getAppBaseUrl", () => { + it("falls back to https://managehub.app when unset", () => { + delete process.env.NEXT_PUBLIC_APP_URL; + + expect(getAppBaseUrl()).toBe("https://managehub.app"); + }); + + it("honours an explicit value and strips the trailing slash", () => { + vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://staging.managehub.app//"); + + expect(getAppBaseUrl()).toBe("https://staging.managehub.app"); + }); + + it("falls back instead of throwing on a malformed value", () => { + vi.stubEnv("NEXT_PUBLIC_APP_URL", "not-a-url"); + + expect(getAppBaseUrl()).toBe("https://managehub.app"); + }); +}); diff --git a/frontend/lib/__tests__/providers.test.tsx b/frontend/lib/__tests__/providers.test.tsx new file mode 100644 index 00000000..4bda27fe --- /dev/null +++ b/frontend/lib/__tests__/providers.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { isAuthError } from "../providers/providers"; + +// Rendering is deliberately skipped: Providers owns a QueryClientProvider, and +// these checks only cover the exported isAuthError helper. +describe("isAuthError", () => { + it("detects a 401 status", () => { + const error = Object.assign(new Error("Request failed"), { + status: 401, + }); + expect(isAuthError(error)).toBe(true); + }); + + it("detects a 403 status", () => { + const error = Object.assign(new Error("Forbidden"), { status: 403 }); + expect(isAuthError(error)).toBe(true); + }); + + it("matches auth-related error messages", () => { + expect(isAuthError(new Error("Unauthorized"))).toBe(true); + expect(isAuthError(new Error("Access forbidden for this resource"))).toBe( + true, + ); + }); + + it("returns false for ordinary errors", () => { + expect(isAuthError(new Error("Network offline"))).toBe(false); + expect(isAuthError(null)).toBe(false); + expect(isAuthError("401")).toBe(false); + }); +}); diff --git a/frontend/lib/__tests__/utils.test.ts b/frontend/lib/__tests__/utils.test.ts index cccda653..098ab2c0 100644 --- a/frontend/lib/__tests__/utils.test.ts +++ b/frontend/lib/__tests__/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatCurrency } from "../utils"; +import { formatCurrency, fromMinorUnits, minorUnitDecimals } from "../utils"; describe("formatCurrency", () => { it("formats a value using the requested locale", () => { @@ -21,3 +21,56 @@ describe("formatCurrency", () => { ); }); }); + +describe("minorUnitDecimals", () => { + it("treats currency codes case-insensitively", () => { + expect(minorUnitDecimals("jpy")).toBe(0); + expect(minorUnitDecimals("bhd")).toBe(3); + }); + + it("falls back to 2 decimals for an unknown currency", () => { + expect(minorUnitDecimals("ZZZ")).toBe(2); + }); + + it("falls back to 2 decimals for an invalid code", () => { + expect(minorUnitDecimals("")).toBe(2); + }); +}); + +describe("fromMinorUnits", () => { + it("converts a zero-decimal currency without dividing", () => { + // With an exponent of 0 there is nothing to divide: 1000 minor units of + // JPY are 1000 yen (the issue text's "=== 10" would imply a /100, which + // contradicts the zero-decimal table and the JPY formatBalance contract). + expect(fromMinorUnits(1000, "JPY")).toBe(1000); + }); + + it("converts a two-decimal currency (cents)", () => { + expect(fromMinorUnits(1257, "USD")).toBe(12.57); + }); + + it("converts a seven-decimal Stellar asset (stroops)", () => { + expect(fromMinorUnits(1234567, "XLM")).toBeCloseTo(0.1234567, 10); + }); + + it("uses the listed exponent with three-decimal currencies", () => { + expect(fromMinorUnits(1234, "BHD")).toBeCloseTo(1.234, 10); + }); + + it("falls back to 2 decimals for an unknown currency", () => { + expect(fromMinorUnits(1250, "ZZZ")).toBeCloseTo(12.5, 10); + }); + + it("maps non-finite input to 0", () => { + expect(fromMinorUnits(Number.NaN, "USD")).toBe(0); + expect(fromMinorUnits(Number.POSITIVE_INFINITY, "USD")).toBe(0); + }); + + it("stays consistent with formatCurrency for a zero-decimal currency", () => { + const formatted = formatCurrency( + fromMinorUnits(10, "JPY"), + "JPY", + ); + expect(formatted).toContain("10"); + }); +}); diff --git a/frontend/lib/admin-api.ts b/frontend/lib/admin-api.ts index fe11a63c..8a0db65d 100644 --- a/frontend/lib/admin-api.ts +++ b/frontend/lib/admin-api.ts @@ -5,6 +5,7 @@ import { getRequestId, reportApiError, } from "@/lib/api-report"; +import { getApiBaseUrl } from "@/lib/env"; export type LedgerAccountKind = | "USER" @@ -155,14 +156,12 @@ export interface SettlementBatchBreakdown { onChainReferences: Array<{ payoutId: string; reference: string }>; } -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; - async function adminFetch( path: string, accessToken: string, init?: RequestInit, ): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { ...init, headers: { "Content-Type": "application/json", diff --git a/frontend/lib/auth-api.ts b/frontend/lib/auth-api.ts index 381d68d0..d091c77f 100644 --- a/frontend/lib/auth-api.ts +++ b/frontend/lib/auth-api.ts @@ -3,6 +3,7 @@ import { getRequestId, reportApiError, } from "@/lib/api-report"; +import { getApiBaseUrl } from "@/lib/env"; export type UserRole = "MEMBER" | "ADMIN" | "SUPER_ADMIN"; @@ -17,13 +18,11 @@ export interface AuthResponse { user: AuthUser; } -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; - async function authRequest( path: string, body: { email: string; password: string }, ): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/frontend/lib/credits-api.ts b/frontend/lib/credits-api.ts index 6a81bf3b..5b035e15 100644 --- a/frontend/lib/credits-api.ts +++ b/frontend/lib/credits-api.ts @@ -1,5 +1,7 @@ // lib/credits-api.ts -- member-facing credit API (FE-108) +import { getApiBaseUrl } from "@/lib/env"; + export interface CreditBalance { accountId: string; currency: string; @@ -26,8 +28,6 @@ export interface CreditStatement { pageSize: number; } -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; - export interface CreditsApiErrorBody { statusCode?: number; message?: string; @@ -72,7 +72,7 @@ async function creditsFetch( accessToken: string, init?: RequestInit, ): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { ...init, headers: { "Content-Type": "application/json", diff --git a/frontend/lib/env.ts b/frontend/lib/env.ts new file mode 100644 index 00000000..b46441f3 --- /dev/null +++ b/frontend/lib/env.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +// The development default for the API base URL (port 6000) aligns with the +// backend's configured PORT (see backend/.env.example), so `npm run dev` on +// the frontend reaches a locally running backend with no configuration. +const DEV_API_BASE_URL = "http://localhost:6000"; +const FALLBACK_APP_BASE_URL = "https://managehub.app"; + +/** + * An absolute http(s) URL, trimmed. Used with safeParse so invalid values are + * handled as data instead of thrown module-load errors. + */ +const httpUrlSchema = z + .string() + .trim() + .url() + .refine( + (value) => /^https?:\/\//i.test(value), + { message: "must be an absolute http(s) URL" }, + ); + +// Validation is lazy (called at request/stream time, never at import time) so +// static prerendering and build-time evaluation of client modules stay safe. +function stripTrailingSlash(url: string): string { + return url.replace(/\/+$/, ""); +} + +/** + * Base URL used for canonical URLs, robots.txt and the sitemap. Falls back to + * the production origin when unset or malformed. + */ +export function getAppBaseUrl(): string { + const raw = process.env.NEXT_PUBLIC_APP_URL; + if (typeof raw !== "string" || raw.trim().length === 0) { + return FALLBACK_APP_BASE_URL; + } + const parsed = httpUrlSchema.safeParse(raw); + // A malformed value must never break rendering: fall back instead of throw. + return parsed.success + ? stripTrailingSlash(parsed.data) + : FALLBACK_APP_BASE_URL; +} + +/** + * Backend API base URL used by every API module (auth, admin, payments, + * wallet, credits). + * + * Behaviour: + * - set to a valid http(s) URL -> returned trimmed, without a trailing slash; + * - unset/empty in development -> the backend's port 6000 default; + * - unset/empty in production -> throws so a misconfigured deploy fails + * loudly instead of silently requesting against the frontend's own origin; + * - unset/empty in any other environment (e.g. test) -> the legacy "" value, + * which keeps requests relative (unit tests rely on this). + */ +export function getApiBaseUrl(): string { + const raw = process.env.NEXT_PUBLIC_API_URL; + if (typeof raw !== "string" || raw.trim().length === 0) { + if (process.env.NODE_ENV === "production") { + throw new Error( + "NEXT_PUBLIC_API_URL is not configured: copy frontend/.env.example to .env.local and set the backend URL (defaults to http://localhost:6000 in development).", + ); + } + if (process.env.NODE_ENV === "development") { + return DEV_API_BASE_URL; + } + return ""; + } + + const parsed = httpUrlSchema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `NEXT_PUBLIC_API_URL is not a valid http(s) URL: got "${raw}". Copy frontend/.env.example to .env.local for the expected format (defaults to http://localhost:6000 in development).`, + ); + } + return stripTrailingSlash(parsed.data); +} diff --git a/frontend/lib/payments-api.ts b/frontend/lib/payments-api.ts index cd906612..9c0683eb 100644 --- a/frontend/lib/payments-api.ts +++ b/frontend/lib/payments-api.ts @@ -3,6 +3,7 @@ import { getRequestId, reportApiError, } from "@/lib/api-report"; +import { getApiBaseUrl } from "@/lib/env"; export type PaymentRail = "FIAT" | "STELLAR_CUSTODIAL" | "STELLAR_EXTERNAL"; @@ -95,7 +96,6 @@ export interface ManualReviewPage { total: number; } -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; const TRANSIENT_RETRY_DELAY_MS = 250; const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]); @@ -153,7 +153,7 @@ async function apiFetch( ...init?.headers, }, }; - const response = await fetchWithRetry(`${API_BASE_URL}${path}`, requestInit); + const response = await fetchWithRetry(`${getApiBaseUrl()}${path}`, requestInit); if (!response.ok) { const body = await response.json().catch(() => null); diff --git a/frontend/lib/providers.tsx b/frontend/lib/providers.tsx deleted file mode 100644 index a66ebc42..00000000 --- a/frontend/lib/providers.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Toaster } from "sonner"; -import { useState } from "react"; - -export function AdminProviders({ children }: { children: React.ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: 1, - }, - }, - }), - ); - - return ( - - {children} - - - ); -} diff --git a/frontend/lib/providers/providers.tsx b/frontend/lib/providers/providers.tsx index e1416634..2ea222bd 100644 --- a/frontend/lib/providers/providers.tsx +++ b/frontend/lib/providers/providers.tsx @@ -1,18 +1,59 @@ "use client"; import { useState } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Toaster } from "sonner"; +import { + QueryCache, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { toast, Toaster } from "sonner"; import { ThemeProvider } from "next-themes"; +/** + * Decide whether an error represents an expired session (401) or a missing + * permission (403). CreditsApiError in lib/credits-api.ts exposes `.status`, + * which is how API errors carry their status here; plain Error objects fall + * back to matching their message. + */ +export function isAuthError(error: unknown): boolean { + if (error instanceof Error) { + const status = (error as { status?: unknown }).status; + if (status === 401 || status === 403) return true; + if ( + error.message.includes("401") || + error.message.includes("403") || + error.message.includes("Unauthorized") || + error.message.includes("Forbidden") + ) { + return true; + } + } + return false; +} + export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => new QueryClient({ + // Clearing the session and redirecting to the login page on a 401 is + // handled at the HTTP layer by the auth-guard work (#1977); this + // configuration only stops pointless retries and surfaces the expiry + // toast. + queryCache: new QueryCache({ + onError: (error) => { + if (isAuthError(error)) { + toast.error( + "Your session has expired. Please sign in again.", + ); + } + }, + }), defaultOptions: { queries: { staleTime: 30_000, - retry: 1, + retry: (failureCount, error) => + !isAuthError(error) && failureCount < 1, refetchOnWindowFocus: false, }, }, @@ -29,6 +70,9 @@ export function Providers({ children }: { children: React.ReactNode }) { > {children} + {process.env.NODE_ENV === "development" && ( + + )} ); diff --git a/frontend/lib/query-provider.tsx b/frontend/lib/query-provider.tsx deleted file mode 100644 index 84a1c4b8..00000000 --- a/frontend/lib/query-provider.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Toaster } from "sonner"; -import { useState } from "react"; - -export function QueryProvider({ children }: { children: React.ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: 1, - }, - }, - }), - ); - - return ( - - {children} - - - ); -} diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index 5f86d098..09fc8ab1 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -5,6 +5,44 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +/** + * Minor-unit decimal places per currency that Intl does not know about or + * that matter regardless of runtime Intl data. Backend amounts arrive as + * integer minor units (see backend/src/payments/utils/minor-units.ts), so + * `fromMinorUnits` has to know each currency's exponent to display a decimal. + * + * - XLM uses 7 decimals (stroops) even though it is not an ISO 4217 code. + * - ISO 4217 zero-decimal and three-decimal currencies are listed explicitly + * so they resolve identically on runtimes with incomplete Intl data. + * Anything not listed here (and any invalid code) falls through to Intl's + * resolved fraction digits, ultimately 2 (the ISO 4217 default). + */ +const MINOR_UNIT_DECIMALS: Record = { + XLM: 7, + JPY: 0, KRW: 0, VND: 0, + BHD: 3, JOD: 3, KWD: 3, OMR: 3, IQD: 3, LYD: 3, TND: 3, +}; + +export function minorUnitDecimals(currency: string): number { + const code = currency.toUpperCase(); + if (code in MINOR_UNIT_DECIMALS) return MINOR_UNIT_DECIMALS[code]; + try { + const digits = new Intl.NumberFormat("en", { style: "currency", currency: code }).resolvedOptions().maximumFractionDigits; + if (typeof digits === "number") return digits; + } catch { /* invalid currency code, fall through */ } + return 2; +} + +/** + * Convert an integer minor-unit amount (what the backend stores and returns) + * into a displayable decimal amount for the currency's real exponent. + * Non-finite input is mapped to 0 so a bad payload cannot break rendering. + */ +export function fromMinorUnits(amount: number, currency: string): number { + if (!Number.isFinite(amount)) return 0; + return amount / 10 ** minorUnitDecimals(currency); +} + export function formatCurrency( amount: number, currency: string, diff --git a/frontend/lib/wallet-api.ts b/frontend/lib/wallet-api.ts index bfe2184d..5c5e82c9 100644 --- a/frontend/lib/wallet-api.ts +++ b/frontend/lib/wallet-api.ts @@ -3,6 +3,7 @@ import { getRequestId, reportApiError, } from "@/lib/api-report"; +import { getApiBaseUrl } from "@/lib/env"; export type WalletCustodyType = "CUSTODIAL" | "EXTERNAL"; export type WalletStatus = "PENDING" | "ACTIVE" | "DISABLED"; @@ -21,10 +22,8 @@ export interface LinkChallengeResponse { expiresAt: string; } -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; - async function walletFetch(path: string, init?: RequestInit): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetch(`${getApiBaseUrl()}${path}`, { ...init, headers: { "Content-Type": "application/json",