diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml
index c3e574c7..dd530d7a 100644
--- a/.github/workflows/CI.yaml
+++ b/.github/workflows/CI.yaml
@@ -147,7 +147,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)
@@ -177,7 +177,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 9930b92f..10b66415 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 { useState } from "react";
import { toast } from "sonner";
@@ -107,7 +108,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 0c13f2b4..ddcad373 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 { UserMenu } from "@/components/auth/user-menu";
import { cn } from "@/lib/utils";
@@ -32,7 +32,7 @@ export default function AdminLayout({
children: React.ReactNode;
}) {
return (
-
+
-
+
);
}
diff --git a/frontend/app/admin/payments/page.tsx b/frontend/app/admin/payments/page.tsx
index 2fc6f8d1..89652996 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 } 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() {
@@ -98,7 +104,7 @@ function ManualReviewInner() {
- {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 c96c22a6..6724da23 100644
--- a/frontend/app/admin/refund/page.tsx
+++ b/frontend/app/admin/refund/page.tsx
@@ -17,6 +17,7 @@ import {
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();
@@ -47,7 +48,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 3777e76c..f9c59696 100644
--- a/frontend/app/credits/page.tsx
+++ b/frontend/app/credits/page.tsx
@@ -8,6 +8,12 @@ import { useSessionStore } from "@/lib/stores/session-store";
import { UserMenu } from "@/components/auth/user-menu";
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;
@@ -79,7 +85,7 @@ function CreditsInner() {
) : (
<>
- {balance.data!.balance.toLocaleString(undefined, {
+ {fromMinorUnits(balance.data!.balance, balance.data!.currency).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
@@ -89,7 +95,7 @@ function CreditsInner() {
{balance.data!.overdraftLimit > 0 && (
- Overdraft limit: {balance.data!.overdraftLimit.toLocaleString()} {balance.data!.currency}
+ Overdraft limit: {formatMinorAmount(balance.data!.overdraftLimit, balance.data!.currency)}
)}
>
@@ -154,10 +160,7 @@ function CreditsInner() {
}`}
>
{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 97fe53e0..dcc7755a 100644
--- a/frontend/app/payments/[id]/page.tsx
+++ b/frontend/app/payments/[id]/page.tsx
@@ -8,9 +8,11 @@ import { useSessionStore } from "@/lib/stores/session-store";
import { usePaymentStatus } from "@/lib/use-payment-status";
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 e1184ff2..e5003e36 100644
--- a/frontend/app/payments/page.tsx
+++ b/frontend/app/payments/page.tsx
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { getPayments } from "@/lib/payments-api";
import { useSessionBootstrap } from "@/lib/stores/use-session-bootstrap";
import { useSessionStore } from "@/lib/stores/session-store";
+import { formatCurrency, fromMinorUnits } from "@/lib/utils";
import { UserMenu } from "@/components/auth/user-menu";
import {
Card,
@@ -14,8 +15,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 2c41e7a6..cdf4010a 100644
--- a/frontend/app/payments/return/page.tsx
+++ b/frontend/app/payments/return/page.tsx
@@ -30,6 +30,7 @@ import { useSessionBootstrap } from "@/lib/stores/use-session-bootstrap";
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 ---------------------------------------------------------
@@ -405,7 +406,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 1600a1ad..fce2b9ba 100644
--- a/frontend/app/usage/page.tsx
+++ b/frontend/app/usage/page.tsx
@@ -2,14 +2,15 @@
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 { useSessionBootstrap } from "@/lib/stores/use-session-bootstrap";
import { useSessionStore } from "@/lib/stores/session-store";
import { UserMenu } from "@/components/auth/user-menu";
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 d310af10..53452c91 100644
--- a/frontend/components/wallet/wallet-status-card.tsx
+++ b/frontend/components/wallet/wallet-status-card.tsx
@@ -12,38 +12,15 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { WalletLinkForm } from "@/components/wallet/wallet-link-form";
+import { fromMinorUnits } from "@/lib/utils";
import { useSignOut } from "@/components/auth/use-sign-out";
-/**
- * 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 08526ad0..053ab4db 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";
import { expireSession } from "@/lib/session-expiry";
export type LedgerAccountKind =
@@ -156,13 +157,11 @@ export interface SettlementBatchBreakdown {
onChainReferences: Array<{ payoutId: string; reference: string }>;
}
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "";
-
async function adminFetch(
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",
diff --git a/frontend/lib/auth-api.ts b/frontend/lib/auth-api.ts
index 42dd3a9b..00134cfd 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";
import { expireSession } from "@/lib/session-expiry";
import type { AuthUser } from "@/lib/auth-types";
@@ -15,8 +16,6 @@ export interface AuthResponse {
user: AuthUser;
}
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "";
-
/**
* Every request includes `credentials: "include"` (issue #1975): the backend
* answers register/login with an HttpOnly `accessToken` cookie, so the session
@@ -27,7 +26,7 @@ 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),
@@ -84,7 +83,7 @@ export function me(): Promise {
}
async function fetchMe(): Promise {
- const response = await fetch(`${API_BASE_URL}/auth/me`, {
+ const response = await fetch(`${getApiBaseUrl()}/auth/me`, {
method: "GET",
credentials: "include",
});
@@ -123,7 +122,7 @@ async function fetchMe(): Promise {
*/
export async function logout(): Promise {
try {
- await fetch(`${API_BASE_URL}/auth/logout`, {
+ await fetch(`${getApiBaseUrl()}/auth/logout`, {
method: "POST",
credentials: "include",
});
diff --git a/frontend/lib/credits-api.ts b/frontend/lib/credits-api.ts
index f1e2f752..59ca1075 100644
--- a/frontend/lib/credits-api.ts
+++ b/frontend/lib/credits-api.ts
@@ -2,6 +2,8 @@
import { expireSession } from "@/lib/session-expiry";
+import { getApiBaseUrl } from "@/lib/env";
+
export interface CreditBalance {
accountId: string;
currency: string;
@@ -28,8 +30,6 @@ export interface CreditStatement {
pageSize: number;
}
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "";
-
export interface CreditsApiErrorBody {
statusCode?: number;
message?: string;
@@ -73,7 +73,7 @@ async function creditsFetch(
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",
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 1a9a492e..009129d9 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]);
@@ -154,7 +154,7 @@ async function apiFetch(
// #1975); no bearer header is threaded from the client.
credentials: "include",
};
- const response = await fetchWithRetry(`${API_BASE_URL}${path}`, requestInit);
+ const response = await fetchWithRetry(`${getApiBaseUrl()}${path}`, requestInit);
if (!response.ok) {
if (response.status === 401) {
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 456110a6..00872c9e 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";
import { expireSession } from "@/lib/session-expiry";
export type WalletCustodyType = "CUSTODIAL" | "EXTERNAL";
@@ -22,10 +23,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",
|