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
52 changes: 52 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: E2E

on:
pull_request:
branches: [main]

jobs:
playwright:
name: Playwright
runs-on: ubuntu-latest
timeout-minutes: 20

defaults:
run:
working-directory: frontend

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: .

# Only Chromium: the suite asserts application behaviour rather than
# rendering differences, so the other engines would add minutes per run
# without adding signal.
- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium

- name: Run E2E suite
run: pnpm test:e2e
env:
# Every API call is intercepted in-test; this only has to be a
# well-formed origin for the intercepts to match against.
NEXT_PUBLIC_API_URL: http://localhost:3001

- name: Upload report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report
retention-days: 7
15 changes: 15 additions & 0 deletions frontend/app/auth/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import type { ReactNode } from "react";
import ThemeToggle from "../../components/ThemeToggle";

import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Sign in",
description:
"Create an AirFlex account or sign in with your phone number to start trading airtime.",
openGraph: {
title: "Sign in",
description:
"Create an AirFlex account or sign in with your phone number to start trading airtime.",
},
// Private to the signed-in user: useful to them, useless in an index.
robots: { index: false, follow: false },
};

/**
* Auth layout — centred card on a violet-tinted background.
* The shared Navbar is already rendered by the root layout above this.
Expand Down
37 changes: 34 additions & 3 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,42 @@ import type { Metadata } from "next";
import Navbar from "../components/Navbar";
import { AuthProvider } from "./context/AuthContext";
import "./globals.css";
import { SITE_DESCRIPTION, SITE_NAME, siteUrl } from "./lib/seo";

export const metadata: Metadata = {
title: "AirFlex — Buy & Sell Airtime Peer-to-Peer",
description:
"AirFlex is an open marketplace for Nigerian airtime and mobile data secured by Soroban escrow contracts on Stellar.",
// metadataBase makes every relative OG/Twitter image resolve to an absolute
// URL. Without it Next.js emits a relative path, which social crawlers cannot
// fetch — the preview silently falls back to no image at all.
metadataBase: new URL(siteUrl()),
title: {
default: `${SITE_NAME} — Buy & Sell Airtime Peer-to-Peer`,
// Per-route titles fill the slot, so a page sets only its own name.
template: `%s | ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
applicationName: SITE_NAME,
openGraph: {
type: "website",
siteName: SITE_NAME,
title: `${SITE_NAME} — Buy & Sell Airtime Peer-to-Peer`,
description: SITE_DESCRIPTION,
url: "/",
images: [
{
url: "/og-default.png",
width: 1200,
height: 630,
alt: `${SITE_NAME} — peer-to-peer airtime marketplace`,
},
],
},
twitter: {
card: "summary_large_image",
title: `${SITE_NAME} — Buy & Sell Airtime Peer-to-Peer`,
description: SITE_DESCRIPTION,
images: ["/og-default.png"],
},
robots: { index: true, follow: true },
};

/**
Expand Down
111 changes: 111 additions & 0 deletions frontend/app/lib/seo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* SEO route policy (Issue #28).
*
* The rule these protect is simple and easy to break by accident: a private
* route must never appear in the sitemap, and must be disallowed in robots.txt.
* Adding a route to one list and forgetting the other is exactly the mistake
* that leaks a wallet URL into a search index.
*/

import robots from "../robots";
import sitemap from "../sitemap";
import { PRIVATE_ROUTES, PUBLIC_ROUTES, isPrivateRoute, siteUrl } from "./seo";

describe("siteUrl", () => {
const original = process.env["NEXT_PUBLIC_SITE_URL"];

afterEach(() => {
if (original === undefined) delete process.env["NEXT_PUBLIC_SITE_URL"];
else process.env["NEXT_PUBLIC_SITE_URL"] = original;
});

it("uses the configured site URL", () => {
process.env["NEXT_PUBLIC_SITE_URL"] = "https://airflex.example";
expect(siteUrl()).toBe("https://airflex.example");
});

it("falls back to localhost rather than throwing", () => {
delete process.env["NEXT_PUBLIC_SITE_URL"];
// metadataBase requires an absolute URL; a missing env var must not take
// down the dev server.
expect(() => new URL(siteUrl())).not.toThrow();
});
});

describe("isPrivateRoute", () => {
it("matches a private route exactly", () => {
expect(isPrivateRoute("/wallet")).toBe(true);
});

it("matches nested paths under a private route", () => {
expect(isPrivateRoute("/admin/users")).toBe(true);
expect(isPrivateRoute("/auth/signup")).toBe(true);
});

it("does not match a public route", () => {
expect(isPrivateRoute("/")).toBe(false);
expect(isPrivateRoute("/sell")).toBe(false);
});

it("does not match a public route that merely starts with the same letters", () => {
expect(isPrivateRoute("/walletsomething")).toBe(false);
});
});

describe("sitemap", () => {
it("lists every public route", () => {
const urls = sitemap().map((entry) => entry.url);

for (const route of PUBLIC_ROUTES) {
expect(urls.some((url) => url.endsWith(route.path))).toBe(true);
}
});

it("excludes every private route, /admin included", () => {
const urls = sitemap().map((entry) => new URL(entry.url).pathname);

for (const priv of PRIVATE_ROUTES) {
expect(urls).not.toContain(priv);
}
expect(urls).not.toContain("/admin");
});

it("emits absolute URLs, which the sitemap spec requires", () => {
for (const entry of sitemap()) {
expect(() => new URL(entry.url)).not.toThrow();
expect(entry.url).toMatch(/^https?:\/\//);
}
});

it("gives the home page the highest priority", () => {
const home = sitemap().find((entry) => new URL(entry.url).pathname === "/");
expect(home?.priority).toBe(1);
});
});

describe("robots", () => {
it("allows crawling the public site", () => {
const rules = robots().rules as { allow?: string | string[] };
expect(rules.allow).toBe("/");
});

it("disallows every private route", () => {
const rules = robots().rules as { disallow?: string[] };

for (const priv of PRIVATE_ROUTES) {
expect(rules.disallow).toContain(`${priv}/`);
}
});

it("keeps /admin out", () => {
const rules = robots().rules as { disallow?: string[] };
expect(rules.disallow).toContain("/admin/");
});

it("points at the sitemap with an absolute URL", () => {
const sitemapUrl = robots().sitemap as string;

expect(sitemapUrl).toMatch(/\/sitemap\.xml$/);
expect(() => new URL(sitemapUrl)).not.toThrow();
});
});
40 changes: 40 additions & 0 deletions frontend/app/lib/seo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Shared SEO constants and helpers (Issue #28).
*
* Kept in one place so the brand name and description cannot drift between the
* root layout, per-route metadata, the sitemap and robots.txt.
*/

export const SITE_NAME = "AirFlex";

export const SITE_DESCRIPTION =
"AirFlex is an open marketplace for Nigerian airtime and mobile data secured by Soroban escrow contracts on Stellar.";

/**
* Absolute site origin.
*
* Falls back to localhost so a developer build still produces valid absolute
* URLs rather than throwing inside `new URL()` — metadataBase requires an
* absolute base, and a missing env var should not break the dev server.
*/
export function siteUrl(): string {
return (
process.env["NEXT_PUBLIC_SITE_URL"] ??
(process.env["VERCEL_URL"] ? `https://${process.env["VERCEL_URL"]}` : null) ??
"http://localhost:3000"
);
}

/** Routes that must never be indexed, and never appear in the sitemap. */
export const PRIVATE_ROUTES = ["/admin", "/profile", "/wallet", "/auth"] as const;

/** Public routes listed in the sitemap, with their relative crawl priority. */
export const PUBLIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" }[] = [
{ path: "/", priority: 1.0, changeFrequency: "daily" },
{ path: "/sell", priority: 0.8, changeFrequency: "weekly" },
];

/** Is this path one the crawlers should be kept out of? */
export function isPrivateRoute(path: string): boolean {
return PRIVATE_ROUTES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
15 changes: 15 additions & 0 deletions frontend/app/profile/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import type { ReactNode } from "react";

import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Profile",
description:
"Manage your AirFlex account details, verification status and trading history.",
openGraph: {
title: "Profile",
description:
"Manage your AirFlex account details, verification status and trading history.",
},
// Private to the signed-in user: useful to them, useless in an index.
robots: { index: false, follow: false },
};

export default function ProfileLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col dark:bg-gray-900">
Expand Down
23 changes: 23 additions & 0 deletions frontend/app/robots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { MetadataRoute } from "next";

import { PRIVATE_ROUTES, siteUrl } from "./lib/seo";

/**
* robots.txt, generated by Next.js (Issue #28).
*
* Private areas are disallowed here *and* carry `robots: { index: false }` in
* their own metadata. Both are needed: robots.txt asks a crawler not to fetch
* the page, while the meta directive is what keeps it out of an index if it
* reaches the URL another way — a shared link, a backlink, or a crawler that
* ignores the file.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: PRIVATE_ROUTES.map((route) => `${route}/`),
},
sitemap: `${siteUrl()}/sitemap.xml`,
};
}
13 changes: 13 additions & 0 deletions frontend/app/sell/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import type { ReactNode } from "react";
import ThemeToggle from "../../components/ThemeToggle";

import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Sell Airtime",
description:
"List airtime or mobile data for sale on AirFlex and get paid in naira once the escrow releases.",
openGraph: {
title: "Sell Airtime",
description:
"List airtime or mobile data for sale on AirFlex and get paid in naira once the escrow releases.",
},
};

export default function SellLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col dark:bg-gray-900">
Expand Down
25 changes: 25 additions & 0 deletions frontend/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { MetadataRoute } from "next";

import { PUBLIC_ROUTES, siteUrl } from "./lib/seo";

/**
* sitemap.xml, generated by Next.js (Issue #28).
*
* Lists only public, stable routes. Wallet, profile, auth and admin are
* deliberately absent — they are private to a signed-in user, so pointing a
* crawler at them wastes its budget and surfaces nothing useful.
*
* Individual trades are not enumerated either: they expire, and a sitemap full
* of dead listings trains a crawler to distrust the file.
*/
export default function sitemap(): MetadataRoute.Sitemap {
const base = siteUrl();
const lastModified = new Date();

return PUBLIC_ROUTES.map((route) => ({
url: `${base}${route.path}`,
lastModified,
changeFrequency: route.changeFrequency,
priority: route.priority,
}));
}
Loading