Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/CI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
59 changes: 59 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example

# vercel
.vercel
Expand Down
16 changes: 16 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions frontend/app/__tests__/robots.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
3 changes: 2 additions & 1 deletion frontend/app/admin/charge/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -107,7 +108,7 @@ export default function AdminChargePage() {
<div className="mt-6 rounded-md border border-gray-200 p-4 text-sm dark:border-gray-800">
<p className="font-medium text-gray-900 dark:text-gray-50">
{result.posted ? "Posted" : "Already applied (not posted)"} — new
balance {result.balanceAfter} {result.currency}
balance {formatCurrency(fromMinorUnits(result.balanceAfter, result.currency), result.currency)}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Transaction {result.transaction.id} ({result.transaction.kind})
Expand Down
6 changes: 3 additions & 3 deletions frontend/app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,7 +32,7 @@ export default function AdminLayout({
children: React.ReactNode;
}) {
return (
<AdminProviders>
<Providers>
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<header className="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
Expand Down Expand Up @@ -64,7 +64,7 @@ export default function AdminLayout({
</main>
</div>
</div>
</AdminProviders>
</Providers>
);
}

Expand Down
8 changes: 7 additions & 1 deletion frontend/app/admin/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() {
Expand Down Expand Up @@ -98,7 +104,7 @@ function ManualReviewInner() {
<StatusBadge status={p.status} />
</td>
<td className="px-5 py-3 text-right tabular-nums">
{p.amount.toLocaleString()} {p.currency}
{formatAmount(p.amount, p.currency)}
</td>
<td className="px-5 py-3 max-w-xs truncate text-gray-500 dark:text-gray-400">
{p.manualReviewReason ?? "—"}
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/admin/refund/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -47,7 +48,7 @@ function PaymentRow({ payment }: { payment: Payment }) {
{payment.id}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{payment.currency} {payment.amount} ·{" "}
{formatCurrency(fromMinorUnits(payment.amount, payment.currency), payment.currency)} ·{" "}
{new Date(payment.createdAt).toLocaleString()}
</p>
</div>
Expand Down
36 changes: 36 additions & 0 deletions frontend/app/components/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ComponentsPage />);

expect(screen.getByText(/Component catalog/)).toBeInTheDocument();
});
});
6 changes: 6 additions & 0 deletions frontend/app/components/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-screen bg-gray-50 px-6 py-12 text-gray-950 dark:bg-gray-950 dark:text-gray-50">
<div className="mx-auto max-w-5xl space-y-10">
Expand Down
15 changes: 9 additions & 6 deletions frontend/app/credits/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -79,7 +85,7 @@ function CreditsInner() {
) : (
<>
<p className="mt-2 text-4xl font-bold tracking-tight">
{balance.data!.balance.toLocaleString(undefined, {
{fromMinorUnits(balance.data!.balance, balance.data!.currency).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
Expand All @@ -89,7 +95,7 @@ function CreditsInner() {
</p>
{balance.data!.overdraftLimit > 0 && (
<p className="mt-1 text-xs text-blue-200">
Overdraft limit: {balance.data!.overdraftLimit.toLocaleString()} {balance.data!.currency}
Overdraft limit: {formatMinorAmount(balance.data!.overdraftLimit, balance.data!.currency)}
</p>
)}
</>
Expand Down Expand Up @@ -154,10 +160,7 @@ function CreditsInner() {
}`}
>
{entry.direction === "CREDIT" ? "+" : "−"}
{entry.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{entry.currency}
{formatMinorAmount(entry.amount, entry.currency)}
</p>
</li>
))}
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/payments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion frontend/app/payments/return/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------------

Expand Down Expand Up @@ -405,7 +406,10 @@ export default function PaymentReturnPage() {

<dt className="text-gray-500 dark:text-gray-400">Amount</dt>
<dd>
{payment.amount.toFixed(2)} {payment.currency}
{formatCurrency(
fromMinorUnits(payment.amount, payment.currency),
payment.currency,
)}
</dd>

{payment.provider && (
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
};
Expand Down
Loading
Loading