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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ jobs:
- name: Lint
run: npm run lint

- name: Typecheck
run: npm run typecheck

- name: Check locale parity
run: npm run check:locales

- name: Build
run: npm run build
env:
Expand Down
16 changes: 13 additions & 3 deletions frontend/app/[locale]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";

export default function NotFound() {
export default async function NotFound({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);

const t = await getTranslations("notFound");

return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-4xl font-bold text-gray-900">404</h1>
<p className="text-sm text-gray-500">
The page you are looking for could not be found.
{t("description")}
</p>
<Link
href="/"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Go back home
{t("home")}
</Link>
</main>
);
Expand Down
63 changes: 63 additions & 0 deletions frontend/i18n/formatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import { useLocale } from "next-intl";

/**
* Locale-aware formatting utilities.
* These hooks use the active locale from next-intl to produce
* correctly formatted dates, numbers, and relative times.
*/

export function useDateFormatting() {
const locale = useLocale();

function formatDate(iso: string, options?: Intl.DateTimeFormatOptions): string {
return new Date(iso).toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}

function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(locale, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

function formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
return new Intl.NumberFormat(locale, options).format(value);
}

function formatPercent(value: number): string {
return new Intl.NumberFormat(locale, {
style: "percent",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value / 100);
}

function formatRelativeTime(iso: string | null): string {
if (!iso) return "Never checked";
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "Never checked";

const diffSeconds = Math.max(0, Math.round((Date.now() - then) / 1000));
if (diffSeconds < 60) return "Checked just now";
const diffMinutes = Math.round(diffSeconds / 60);
if (diffMinutes < 60)
return `Checked ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} ago`;
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24)
return `Checked ${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
const diffDays = Math.round(diffHours / 24);
return `Checked ${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
}

return { formatDate, formatDateTime, formatNumber, formatPercent, formatRelativeTime };
}
27 changes: 27 additions & 0 deletions frontend/i18n/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Type-safe message keys derived from en.json.
* Import this type and use it to ensure translation key references
* are compile-time safe.
*
* Usage:
* import { type Messages } from "@/i18n/messages";
* const t = useTranslations<Messages>("namespace");
*/

import enMessages from "../messages/en.json";

export type Messages = typeof enMessages;

/**
* Extracts all dot-separated key paths from a nested object type.
* e.g., { a: { b: string } } => "a" | "a.b"
*/
type DotPrefix<T extends string> = T extends "" ? "" : `.${T}`;

type DotKeys<T extends Record<string, unknown>> = {
[K in keyof T & string]: T[K] extends Record<string, unknown>
? `${K}${DotPrefix<DotKeys<T[K]>>}`
: K;
}[keyof T & string];

export type MessageKeys = DotKeys<Messages>;
10 changes: 10 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,15 @@
"title": "Language",
"description": "Choose the language used across the interface and in the emails we send you."
}
},
"errors": {
"title": "Something went wrong",
"description": "An unexpected error occurred. Please try again.",
"tryAgain": "Try again",
"goHome": "Go back home"
},
"notFound": {
"description": "The page you are looking for could not be found.",
"home": "Go back home"
}
}
10 changes: 10 additions & 0 deletions frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,15 @@
"title": "Idioma",
"description": "Elige el idioma utilizado en la interfaz y en los correos que te enviamos."
}
},
"errors": {
"title": "Algo salió mal",
"description": "Ocurrió un error inesperado. Por favor, inténtalo de nuevo.",
"tryAgain": "Intentar de nuevo",
"goHome": "Volver al inicio"
},
"notFound": {
"description": "No se pudo encontrar la página que buscas.",
"home": "Volver al inicio"
}
}
10 changes: 10 additions & 0 deletions frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,15 @@
"title": "Langue",
"description": "Choisissez la langue utilisée dans l'interface et dans les e-mails que nous vous envoyons."
}
},
"errors": {
"title": "Une erreur est survenue",
"description": "Une erreur inattendue s'est produite. Veuillez réessayer.",
"tryAgain": "Réessayer",
"goHome": "Retour à l'accueil"
},
"notFound": {
"description": "La page que vous recherchez est introuvable.",
"home": "Retour à l'accueil"
}
}
23 changes: 21 additions & 2 deletions frontend/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import createMiddleware from "next-intl/middleware";
import { createMiddleware, type LocalePrefix } from "next-intl/middleware";
import { routing } from "./i18n/routing";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

/**
* next-intl middleware — handles locale detection (via the `NEXT_LOCALE`
Expand All @@ -14,7 +16,24 @@ import { routing } from "./i18n/routing";
* return response;
* }
*/
export default createMiddleware(routing);
const intlMiddleware = createMiddleware(routing);

export default function middleware(request: NextRequest) {
// Extract the locale segment from the URL
const pathname = request.nextUrl.pathname;
const segments = pathname.split("/").filter(Boolean);
const firstSegment = segments[0];

// If the first segment looks like a locale but isn't supported,
// redirect to the default locale rather than showing an error.
if (firstSegment && firstSegment.length === 2 && !routing.locales.includes(firstSegment as any)) {
const url = request.nextUrl.clone();
url.pathname = `/${routing.defaultLocale}${pathname}`;
return NextResponse.redirect(url);
}

return intlMiddleware(request);
}

export const config = {
// Skip Next.js internals, API routes and static files (anything with a dot).
Expand Down
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"check:locales": "node scripts/check-locales.mjs",
"check:hardcoded": "node scripts/check-hardcoded-strings.mjs",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
Expand Down
87 changes: 87 additions & 0 deletions frontend/scripts/check-hardcoded-strings.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env node

/**
* CI script: Detect hardcoded user-facing strings in JSX/TSX files.
* Flags text between HTML tags that isn't wrapped in a translation function.
*
* This is a pragmatic check — not a full AST parser. It catches obvious
* cases like <p>Hello world</p> but allows:
* - Strings inside t() calls
* - className attributes
* - data-testid attributes
* - aria-label with t() wrapper
* - Short technical strings (< 3 chars)
*/

import { readFileSync, readdirSync, statSync } from "fs";
import { resolve, dirname, relative } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const frontendDir = resolve(__dirname, "..");

function getTsxFiles(dir) {
const files = [];
for (const entry of readdirSync(dir)) {
const full = resolve(dir, entry);
if (entry === "node_modules" || entry === ".next" || entry === "public") continue;
if (statSync(full).isDirectory()) {
files.push(...getTsxFiles(full));
} else if (/\.(tsx|jsx)$/.test(entry) && !entry.endsWith(".d.ts")) {
files.push(full);
}
}
return files;
}

// Pattern: text content between > and < that looks like user-facing text
const HARDCODED_PATTERN = />([A-Z][a-zA-Z\s]{4,})</g;
// Ignore patterns
const IGNORE_PATTERNS = [
/className=/,
/data-testid=/,
/aria-label=\{.*t\(/,
/import /,
/export /,
/console\./,
/\/\//,
/\{\/\*/, // JSX comments
];

let hasWarnings = false;
const files = getTsxFiles(frontendDir);

for (const file of files) {
const relPath = relative(frontendDir, file);
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");

for (let i = 0; i < lines.length; i++) {
const line = lines[i];

// Skip lines that use t() or have ignore patterns
if (IGNORE_PATTERNS.some((p) => p.test(line))) continue;
if (line.includes("t(") || line.includes("getTranslations")) continue;

const matches = [...line.matchAll(HARDCODED_PATTERN)];
for (const match of matches) {
const text = match[1].trim();
// Skip very short or obviously non-user-facing strings
if (text.length < 5) continue;
if (/^[A-Z_]+$/.test(text)) continue; // constants like "PDF", "URL"
if (/^\d/.test(text)) continue; // starts with number

console.warn(`⚠ ${relPath}:${i + 1} — Possible hardcoded string: "${text}"`);
hasWarnings = true;
}
}
}

if (hasWarnings) {
console.log("\n💡 Consider wrapping hardcoded strings with translation functions (t()).");
console.log(" These warnings don't fail CI but help maintain i18n coverage.");
// Don't fail CI yet — this is advisory
process.exit(0);
} else {
console.log("✓ No obvious hardcoded user-facing strings found.");
}
67 changes: 67 additions & 0 deletions frontend/scripts/check-locales.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env node

/**
* CI script: Verify all locale files have identical key sets.
* Exits with code 1 if key sets diverge.
*/

import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const messagesDir = resolve(__dirname, "..", "messages");

const locales = ["en", "es", "fr"];

function flattenKeys(obj, prefix = "") {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
keys.push(...flattenKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}

let allKeys = {};
let hasError = false;

for (const locale of locales) {
const filePath = resolve(messagesDir, `${locale}.json`);
try {
const content = JSON.parse(readFileSync(filePath, "utf-8"));
allKeys[locale] = flattenKeys(content);
} catch (err) {
console.error(`Failed to read ${filePath}: ${err.message}`);
process.exit(1);
}
}

const referenceLocale = "en";
const referenceKeys = allKeys[referenceLocale];

for (const locale of locales.filter((l) => l !== referenceLocale)) {
const localeKeys = allKeys[locale];
const missingInLocale = referenceKeys.filter((k) => !localeKeys.includes(k));
const extraInLocale = localeKeys.filter((k) => !referenceKeys.includes(k));

if (missingInLocale.length > 0) {
console.error(`[${locale}] Missing keys: ${missingInLocale.join(", ")}`);
hasError = true;
}
if (extraInLocale.length > 0) {
console.error(`[${locale}] Extra keys: ${extraInLocale.join(", ")}`);
hasError = true;
}
}

if (hasError) {
console.error("\nLocale key sets are not in sync. Please update all locale files.");
process.exit(1);
} else {
console.log(`✓ All locale files (${locales.join(", ")}) have identical key sets (${referenceKeys.length} keys).`);
}
Loading