diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5d91874b..d9538390 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -69,6 +69,9 @@ jobs:
- name: Lint
run: npm run lint
+ - name: Typecheck
+ run: npm run typecheck
+
- name: Build
run: npm run build
env:
diff --git a/frontend/app/[locale]/(protected)/admin/error.tsx b/frontend/app/[locale]/(protected)/admin/error.tsx
new file mode 100644
index 00000000..7a0daac1
--- /dev/null
+++ b/frontend/app/[locale]/(protected)/admin/error.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+
+export default function AdminError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ const t = useTranslations("errors");
+
+ useEffect(() => {
+ console.error("Admin route error:", error);
+ }, [error]);
+
+ return (
+
+ {t("title")}
+ {t("description")}
+
+
+
+ {t("goHome")}
+
+
+
+ );
+}
diff --git a/frontend/app/[locale]/(protected)/admin/loading.tsx b/frontend/app/[locale]/(protected)/admin/loading.tsx
new file mode 100644
index 00000000..83294e1b
--- /dev/null
+++ b/frontend/app/[locale]/(protected)/admin/loading.tsx
@@ -0,0 +1,19 @@
+import Skeleton from "@/components/Skeleton";
+
+export default function AdminLoading() {
+ return (
+
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/app/[locale]/(protected)/error.tsx b/frontend/app/[locale]/(protected)/error.tsx
new file mode 100644
index 00000000..ed8f2d65
--- /dev/null
+++ b/frontend/app/[locale]/(protected)/error.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+
+export default function ProtectedError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ const t = useTranslations("errors");
+
+ useEffect(() => {
+ console.error("Protected route error:", error);
+ }, [error]);
+
+ return (
+
+ {t("title")}
+ {t("description")}
+
+
+
+ {t("goHome")}
+
+
+
+ );
+}
diff --git a/frontend/app/[locale]/(protected)/loading.tsx b/frontend/app/[locale]/(protected)/loading.tsx
new file mode 100644
index 00000000..4b4929ec
--- /dev/null
+++ b/frontend/app/[locale]/(protected)/loading.tsx
@@ -0,0 +1,18 @@
+import Skeleton from "@/components/Skeleton";
+
+export default function ProtectedLoading() {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/app/[locale]/(protected)/reports/loading.tsx b/frontend/app/[locale]/(protected)/reports/loading.tsx
new file mode 100644
index 00000000..f4552dd4
--- /dev/null
+++ b/frontend/app/[locale]/(protected)/reports/loading.tsx
@@ -0,0 +1,15 @@
+import Skeleton from "@/components/Skeleton";
+
+export default function ReportsLoading() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/[locale]/error.tsx b/frontend/app/[locale]/error.tsx
new file mode 100644
index 00000000..e5e9044a
--- /dev/null
+++ b/frontend/app/[locale]/error.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useEffect } from "react";
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+
+export default function LocaleError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ const t = useTranslations("errors");
+
+ useEffect(() => {
+ console.error("Route error:", error);
+ }, [error]);
+
+ return (
+
+ {t("title")}
+ {t("description")}
+
+
+
+ {t("goHome")}
+
+
+
+ );
+}
diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx
new file mode 100644
index 00000000..62bf04b4
--- /dev/null
+++ b/frontend/app/global-error.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useEffect } from "react";
+import { Link } from "@/i18n/navigation";
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error("Global error:", error);
+ }, [error]);
+
+ return (
+
+ Something went wrong
+
+ An unexpected error occurred. Please try again.
+
+
+
+
+ Go back home
+
+
+
+ );
+}
diff --git a/frontend/cmmty/component-test-example.test.tsx b/frontend/cmmty/component-test-example.test.tsx
new file mode 100644
index 00000000..1bf05490
--- /dev/null
+++ b/frontend/cmmty/component-test-example.test.tsx
@@ -0,0 +1,86 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "./test-utils";
+
+function SimpleButton({ label, onClick }: { label: string; onClick: () => void }) {
+ return (
+
+ );
+}
+
+function DataDisplay({ value, label }: { value: string | number; label: string }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
+
+function Counter() {
+ const [count, setCount] = React.useState(0);
+ return (
+
+
{count}
+
+
+
+ );
+}
+
+describe("Component testing reference patterns", () => {
+ describe("Simple component test", () => {
+ it("renders a button with the correct label", () => {
+ render( {}} />);
+ expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
+ });
+
+ it("calls onClick when button is clicked", async () => {
+ const user = userEvent.setup();
+ const handleClick = jest.fn();
+ render();
+ await user.click(screen.getByRole("button", { name: /click me/i }));
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Data display component test", () => {
+ it("renders label and value", () => {
+ render();
+ expect(screen.getByText("Score")).toBeInTheDocument();
+ expect(screen.getByText("42")).toBeInTheDocument();
+ });
+
+ it("renders numeric values", () => {
+ render();
+ expect(screen.getByText("99")).toBeInTheDocument();
+ });
+ });
+
+ describe("Form/interaction component test", () => {
+ it("increments counter on click", async () => {
+ const user = userEvent.setup();
+ render();
+ expect(screen.getByTestId("count")).toHaveTextContent("0");
+ await user.click(screen.getByRole("button", { name: /increment/i }));
+ expect(screen.getByTestId("count")).toHaveTextContent("1");
+ });
+
+ it("decrements counter on click", async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: /decrement/i }));
+ expect(screen.getByTestId("count")).toHaveTextContent("-1");
+ });
+ });
+
+ describe("Provider-wrapped component test", () => {
+ it("renders with intl provider", () => {
+ renderWithProviders( {}} />);
+ expect(screen.getByRole("button", { name: /test/i })).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/cmmty/test-setup.ts b/frontend/cmmty/test-setup.ts
index c0aa0911..c1c6baf6 100644
--- a/frontend/cmmty/test-setup.ts
+++ b/frontend/cmmty/test-setup.ts
@@ -1 +1,10 @@
-// Jest setup file for cmmty tests
+import "@testing-library/jest-dom";
+
+// Silence next-intl warnings in test output
+beforeEach(() => {
+ jest.spyOn(console, "warn").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
diff --git a/frontend/cmmty/test-utils.tsx b/frontend/cmmty/test-utils.tsx
new file mode 100644
index 00000000..0ad14131
--- /dev/null
+++ b/frontend/cmmty/test-utils.tsx
@@ -0,0 +1,62 @@
+import React, { ReactElement } from "react";
+import { render, RenderOptions } from "@testing-library/react";
+import { NextIntlClientProvider } from "next-intl";
+
+const testMessages = {
+ common: {
+ appName: "SMALDA",
+ loading: "Loading…",
+ save: "Save",
+ cancel: "Cancel",
+ previous: "Previous",
+ next: "Next",
+ },
+ nav: {
+ dashboard: "Dashboard",
+ documents: "Documents",
+ users: "Users",
+ settings: "Settings",
+ signOut: "Sign out",
+ },
+ errors: {
+ title: "Something went wrong",
+ description: "An unexpected error occurred.",
+ tryAgain: "Try again",
+ goHome: "Go back home",
+ },
+ auth: {
+ login: {
+ title: "Sign in to SMALDA",
+ submit: "Sign in",
+ submitting: "Signing in…",
+ },
+ },
+ dashboard: {
+ title: "Dashboard",
+ welcome: "Welcome back",
+ subtitle: "Here's an overview.",
+ stats: { total: "Total", verified: "Verified", flagged: "Flagged", pending: "Pending" },
+ },
+};
+
+interface CustomRenderOptions extends Omit {
+ locale?: string;
+}
+
+function AllProviders({ children, locale = "en" }: { children: React.ReactNode; locale?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function renderWithProviders(ui: ReactElement, options: CustomRenderOptions = {}) {
+ const { locale, ...renderOptions } = options;
+ return render(ui, {
+ wrapper: ({ children }) => {children},
+ ...renderOptions,
+ });
+}
+
+export { testMessages };
diff --git a/frontend/components/Skeleton.tsx b/frontend/components/Skeleton.tsx
new file mode 100644
index 00000000..0dfbe31a
--- /dev/null
+++ b/frontend/components/Skeleton.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+interface SkeletonProps {
+ className?: string;
+ width?: string;
+ height?: string;
+}
+
+export default function Skeleton({ className = "", width, height }: SkeletonProps) {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
+ setPrefersReducedMotion(mq.matches);
+
+ const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
+ mq.addEventListener("change", handler);
+ return () => mq.removeEventListener("change", handler);
+ }, []);
+
+ return (
+
+ Loading…
+
+ );
+}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 069d1cb1..d33c0f4f 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -109,5 +109,11 @@
"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"
}
}
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 6440bfe2..404c372e 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -109,5 +109,11 @@
"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"
}
}
diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json
index a7047db8..bd2a3b09 100644
--- a/frontend/messages/fr.json
+++ b/frontend/messages/fr.json
@@ -109,5 +109,11 @@
"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"
}
}
diff --git a/frontend/package.json b/frontend/package.json
index fab73221..2992f1b7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "typecheck": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
@@ -33,6 +34,7 @@
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^16.0.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/leaflet": "^1.9.20",
"@types/node": "^20",
"@types/react": "^19.1.12",