From 19fb8be7fb8dda272177964bb2afd07e51fca1f7 Mon Sep 17 00:00:00 2001
From: simoleo89
Date: Mon, 31 Aug 2026 18:44:56 +0200
Subject: [PATCH] feat(housekeeping): add localized route states
---
src/app/ase-next/error.tsx | 54 +++++++
src/app/ase-next/forbidden.tsx | 38 +++--
src/app/ase-next/loading.tsx | 19 +++
src/app/ase-next/not-found.tsx | 38 +++++
.../foundation/app-boundaries.test.tsx | 132 ++++++++++++++++++
.../foundation/localization-contract.test.ts | 22 +++
.../page/housekeeping-page-state.test.tsx | 19 +++
.../page/housekeeping-page-state.tsx | 46 +++++-
.../foundation/preview-route-contract.test.ts | 5 +
src/messages/en.json | 22 ++-
src/messages/it.json | 22 ++-
src/messages/nl.json | 81 +++++++++++
12 files changed, 468 insertions(+), 30 deletions(-)
create mode 100644 src/app/ase-next/error.tsx
create mode 100644 src/app/ase-next/loading.tsx
create mode 100644 src/app/ase-next/not-found.tsx
create mode 100644 src/features/housekeeping/foundation/app-boundaries.test.tsx
diff --git a/src/app/ase-next/error.tsx b/src/app/ase-next/error.tsx
new file mode 100644
index 00000000..a2967501
--- /dev/null
+++ b/src/app/ase-next/error.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import Link from "@/components/link";
+import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
+
+interface HousekeepingErrorProps {
+ error: Error & { digest?: string };
+ reset: () => void;
+}
+
+export default function HousekeepingError({
+ error,
+ reset,
+}: HousekeepingErrorProps) {
+ const translate = useTranslations("pages.housekeeping");
+
+ return (
+
+
+
+
+
+ {translate("states.backToHousekeeping")}
+
+
+ }
+ />
+ {error.digest ? (
+
+ {translate("states.supportReference", {
+ reference: error.digest,
+ })}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/app/ase-next/forbidden.tsx b/src/app/ase-next/forbidden.tsx
index a5f5c374..7c12b2f9 100644
--- a/src/app/ase-next/forbidden.tsx
+++ b/src/app/ase-next/forbidden.tsx
@@ -1,32 +1,28 @@
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
+import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
export default async function HousekeepingForbidden() {
const translate = await getTranslations("pages.housekeeping");
return (
-
-
- {translate("states.forbidden.title")}
-
-
- {translate("states.forbidden.description")}
-
-
- {translate("preview.backToSite")}
-
-
+
+
+ {translate("states.backToSite")}
+
+ }
+ />
+
);
}
diff --git a/src/app/ase-next/loading.tsx b/src/app/ase-next/loading.tsx
new file mode 100644
index 00000000..2f33f175
--- /dev/null
+++ b/src/app/ase-next/loading.tsx
@@ -0,0 +1,19 @@
+import { getTranslations } from "next-intl/server";
+import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
+
+export default async function HousekeepingLoading() {
+ const translate = await getTranslations("pages.housekeeping");
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/app/ase-next/not-found.tsx b/src/app/ase-next/not-found.tsx
new file mode 100644
index 00000000..18b6ccfc
--- /dev/null
+++ b/src/app/ase-next/not-found.tsx
@@ -0,0 +1,38 @@
+import { getTranslations } from "next-intl/server";
+import Link from "@/components/link";
+import { env } from "@/env";
+import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
+import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
+
+export default async function HousekeepingNotFound() {
+ const translate = await getTranslations("pages.housekeeping");
+ const previewEnabled = isHousekeepingPreviewEnabled({
+ nodeEnv: env.NODE_ENV,
+ flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
+ });
+ const returnHref = previewEnabled ? "/ase-next" : "/";
+ const returnLabel = previewEnabled
+ ? translate("states.backToHousekeeping")
+ : translate("states.backToSite");
+
+ return (
+
+
+
+ {returnLabel}
+
+ }
+ />
+
+
+ );
+}
diff --git a/src/features/housekeeping/foundation/app-boundaries.test.tsx b/src/features/housekeeping/foundation/app-boundaries.test.tsx
new file mode 100644
index 00000000..2a8d4047
--- /dev/null
+++ b/src/features/housekeeping/foundation/app-boundaries.test.tsx
@@ -0,0 +1,132 @@
+import { isValidElement, type ReactElement, type ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const boundaryMocks = vi.hoisted(() => {
+ const messages: Record = {
+ "states.loading.title": "HK::loading-title",
+ "states.loading.description": "HK::loading-description",
+ "states.notFound.title": "HK::not-found-title",
+ "states.notFound.description": "HK::not-found-description",
+ "states.error.title": "HK::error-title",
+ "states.error.description": "HK::error-description",
+ "states.retry": "HK::retry",
+ "states.backToSite": "HK::back-to-site",
+ "states.backToHousekeeping": "HK::back-to-housekeeping",
+ };
+
+ return {
+ env: {
+ NODE_ENV: "test" as "development" | "test" | "production",
+ HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
+ },
+ translate: vi.fn(
+ (key: string, values?: Record) => {
+ if (key === "states.supportReference") {
+ return `HK::support-reference:${values?.reference}`;
+ }
+
+ const message = messages[key];
+ if (message === undefined) {
+ throw new Error(`Unexpected translation: ${key}`);
+ }
+
+ return message;
+ },
+ ),
+ };
+});
+
+vi.mock("@/env", () => ({ env: boundaryMocks.env }));
+vi.mock("next-intl", () => ({
+ useTranslations: () => boundaryMocks.translate,
+}));
+vi.mock("next-intl/server", () => ({
+ getTranslations: vi.fn(async () => boundaryMocks.translate),
+}));
+
+import HousekeepingRouteLoading from "@/app/ase-next/[domain]/[[...segments]]/loading";
+import HousekeepingError from "@/app/ase-next/error";
+import HousekeepingLoading from "@/app/ase-next/loading";
+import HousekeepingNotFound from "@/app/ase-next/not-found";
+
+function findElementByType(
+ node: ReactNode,
+ type: string,
+): ReactElement> | undefined {
+ if (!isValidElement(node)) return undefined;
+ if (node.type === type) {
+ return node as ReactElement>;
+ }
+
+ for (const value of Object.values(node.props as Record)) {
+ const candidates = Array.isArray(value) ? value : [value];
+ for (const candidate of candidates) {
+ const match = findElementByType(candidate as ReactNode, type);
+ if (match) return match;
+ }
+ }
+
+ return undefined;
+}
+
+describe("/ase-next App Router boundaries", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ boundaryMocks.env.NODE_ENV = "test";
+ boundaryMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
+ });
+
+ it.each([
+ ["root", () => HousekeepingLoading()],
+ ["route", () => HousekeepingRouteLoading()],
+ ] as const)(
+ "renders the localized %s loading boundary",
+ async (_scope, view) => {
+ const html = renderToStaticMarkup(await view());
+
+ expect(html).toContain("HK::loading-title");
+ expect(html).toContain("HK::loading-description");
+ expect(html).toContain('aria-live="polite"');
+ },
+ );
+
+ it("offers a safe housekeeping return for a missing preview route", async () => {
+ const html = renderToStaticMarkup(await HousekeepingNotFound());
+
+ expect(html).toContain("HK::not-found-title");
+ expect(html).toContain("HK::not-found-description");
+ expect(html).toContain('href="/ase-next"');
+ expect(html).toContain("HK::back-to-housekeeping");
+ });
+
+ it("returns to the site when the preview itself is unavailable", async () => {
+ boundaryMocks.env.NODE_ENV = "production";
+ boundaryMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = false;
+
+ const html = renderToStaticMarkup(await HousekeepingNotFound());
+
+ expect(html).toContain('href="/"');
+ expect(html).toContain("HK::back-to-site");
+ expect(html).not.toContain('href="/ase-next"');
+ });
+
+ it("renders only a safe digest and wires the retry action", () => {
+ const reset = vi.fn();
+ const error = Object.assign(new Error("database password leaked"), {
+ digest: "digest-123",
+ });
+ const view = HousekeepingError({ error, reset });
+ const html = renderToStaticMarkup(view);
+
+ expect(html).toContain("HK::error-title");
+ expect(html).toContain("HK::error-description");
+ expect(html).toContain("HK::support-reference:digest-123");
+ expect(html).not.toContain("database password leaked");
+ const button = findElementByType(view, "button");
+ expect(button).toBeDefined();
+ expect(button?.props.onClick).toBe(reset);
+ (button?.props.onClick as (() => void) | undefined)?.();
+ expect(reset).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/features/housekeeping/foundation/localization-contract.test.ts b/src/features/housekeeping/foundation/localization-contract.test.ts
index 82239e82..0f5c6397 100644
--- a/src/features/housekeeping/foundation/localization-contract.test.ts
+++ b/src/features/housekeeping/foundation/localization-contract.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import en from "@/messages/en.json";
import itMessages from "@/messages/it.json";
+import nlMessages from "@/messages/nl.json";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
const requiredKeys = [
@@ -21,6 +22,18 @@ const requiredKeys = [
"pages.housekeeping.states.error.description",
"pages.housekeeping.states.forbidden.title",
"pages.housekeeping.states.forbidden.description",
+ "pages.housekeeping.states.conflict.title",
+ "pages.housekeeping.states.conflict.description",
+ "pages.housekeeping.states.dependency.title",
+ "pages.housekeeping.states.dependency.description",
+ "pages.housekeeping.states.notFound.title",
+ "pages.housekeeping.states.notFound.description",
+ "pages.housekeeping.states.success.title",
+ "pages.housekeeping.states.success.description",
+ "pages.housekeeping.states.retry",
+ "pages.housekeeping.states.backToSite",
+ "pages.housekeeping.states.backToHousekeeping",
+ "pages.housekeeping.states.supportReference",
];
const expectedDomainMessages = [
@@ -68,6 +81,7 @@ describe("housekeeping localization contract", () => {
it.each([
["English", en],
["Italian", itMessages],
+ ["Dutch", nlMessages],
])("provides the planned housekeeping subtree in %s", (_locale, messages) => {
const housekeeping = resolveMessage(messages, "pages.housekeeping");
@@ -115,4 +129,12 @@ describe("housekeeping localization contract", () => {
),
).toBe("Stiamo preparando gli strumenti di housekeeping disponibili.");
});
+ it("uses idiomatic Dutch copy for access and missing states", () => {
+ expect(
+ resolveMessage(nlMessages, "pages.housekeeping.states.forbidden.title"),
+ ).toBe("Toegang geweigerd");
+ expect(
+ resolveMessage(nlMessages, "pages.housekeeping.states.notFound.title"),
+ ).toBe("Pagina niet gevonden");
+ });
});
diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx
index 13c02396..da4cb683 100644
--- a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx
+++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx
@@ -10,7 +10,12 @@ describe("HousekeepingPageState", () => {
["loading", "status"],
["empty", "status"],
["partial", "status"],
+ ["conflict", "alert"],
+ ["dependency", "alert"],
+ ["forbidden", "alert"],
["error", "alert"],
+ ["not-found", "status"],
+ ["success", "status"],
] as const)("renders %s with the %s role", (state, role) => {
const pageState =
state === "partial" ? (
@@ -46,6 +51,20 @@ describe("HousekeepingPageState", () => {
expect(loading).toContain('aria-live="polite"');
});
+ it("supports a page-level heading for router boundaries", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Missing page
");
+ });
+
it.each([
["English", en.pages.housekeeping.states.partial],
["Italian", itMessages.pages.housekeeping.states.partial],
diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.tsx
index e6cf52ed..bb5ec827 100644
--- a/src/features/housekeeping/foundation/page/housekeeping-page-state.tsx
+++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.tsx
@@ -3,35 +3,65 @@ import type { ReactNode } from "react";
interface HousekeepingPageStateBaseProps {
title: string;
description: string;
+ headingLevel?: "h1" | "h2";
retryAction?: ReactNode;
}
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
(
| { state: "partial"; partialLabel: string }
- | { state: "loading" | "empty" | "error"; partialLabel?: never }
+ | {
+ state:
+ | "loading"
+ | "empty"
+ | "conflict"
+ | "dependency"
+ | "forbidden"
+ | "error"
+ | "not-found"
+ | "success";
+ partialLabel?: never;
+ }
);
+const ALERT_STATES = new Set(["conflict", "dependency", "forbidden", "error"]);
+
export function HousekeepingPageState({
state,
partialLabel,
title,
description,
+ headingLevel = "h2",
retryAction,
}: HousekeepingPageStateProps) {
- const isError = state === "error";
+ const isAlert = ALERT_STATES.has(state);
const isPartial = state === "partial";
+ const isWarning = isPartial || state === "conflict" || state === "dependency";
+ const isError = state === "error" || state === "forbidden";
+ const isSuccess = state === "success";
+ const Heading = headingLevel;
return (
{isPartial ? (
@@ -42,7 +72,9 @@ export function HousekeepingPageState({
{partialLabel}
) : null}
- {title}
+
+ {title}
+
{description}
diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts
index 2b745945..748d6443 100644
--- a/src/features/housekeeping/foundation/preview-route-contract.test.ts
+++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts
@@ -28,6 +28,7 @@ const routeMocks = vi.hoisted(() => {
"domains.economy.description": "Localized Economy description",
"states.forbidden.title": "HK::access-denied",
"states.forbidden.description": "Localized insufficient access",
+ "states.backToSite": "HK::back-to-site",
"states.empty.title": "Localized empty title",
"states.empty.description": "Localized empty description",
};
@@ -196,9 +197,13 @@ import AdminNextPage from "@/app/ase-next/page";
const routeFiles = [
"src/app/ase-next/layout.tsx",
+ "src/app/ase-next/error.tsx",
"src/app/ase-next/forbidden.tsx",
+ "src/app/ase-next/loading.tsx",
+ "src/app/ase-next/not-found.tsx",
"src/app/ase-next/page.tsx",
"src/app/ase-next/[domain]/layout.tsx",
+ "src/app/ase-next/[domain]/[[...segments]]/loading.tsx",
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
] as const;
diff --git a/src/messages/en.json b/src/messages/en.json
index e2ac0ba0..c34dffbc 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -3322,7 +3322,27 @@
"forbidden": {
"title": "Access denied",
"description": "Your account does not have permission to use this housekeeping area."
- }
+ },
+ "conflict": {
+ "title": "Update conflict",
+ "description": "This information changed while you were working. Reload it before trying again."
+ },
+ "dependency": {
+ "title": "Service unavailable",
+ "description": "A required housekeeping service is unavailable. Try again later."
+ },
+ "notFound": {
+ "title": "Page not found",
+ "description": "This housekeeping route does not exist or is no longer available."
+ },
+ "success": {
+ "title": "Changes saved",
+ "description": "The housekeeping operation completed successfully."
+ },
+ "retry": "Try again",
+ "backToSite": "Back to site",
+ "backToHousekeeping": "Back to housekeeping",
+ "supportReference": "Support reference: {reference}"
}
}
}
diff --git a/src/messages/it.json b/src/messages/it.json
index 45375e86..33a1f92f 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -3321,7 +3321,27 @@
"forbidden": {
"title": "Accesso negato",
"description": "Il tuo account non dispone dei permessi necessari per usare questa area di housekeeping."
- }
+ },
+ "conflict": {
+ "title": "Conflitto di aggiornamento",
+ "description": "Queste informazioni sono cambiate mentre lavoravi. Ricaricale prima di riprovare."
+ },
+ "dependency": {
+ "title": "Servizio non disponibile",
+ "description": "Un servizio necessario per housekeeping non è disponibile. Riprova più tardi."
+ },
+ "notFound": {
+ "title": "Pagina non trovata",
+ "description": "Questa pagina housekeeping non esiste o non è più disponibile."
+ },
+ "success": {
+ "title": "Modifiche salvate",
+ "description": "L’operazione housekeeping è stata completata correttamente."
+ },
+ "retry": "Riprova",
+ "backToSite": "Torna al sito",
+ "backToHousekeeping": "Torna a housekeeping",
+ "supportReference": "Riferimento assistenza: {reference}"
}
}
}
diff --git a/src/messages/nl.json b/src/messages/nl.json
index 7e00beb5..0e5df49a 100644
--- a/src/messages/nl.json
+++ b/src/messages/nl.json
@@ -3168,6 +3168,87 @@
"teamHint": "Online staff & werklast"
}
},
+ "housekeeping": {
+ "preview": {
+ "badge": "Voorbeeld van de basis",
+ "commandDisabled": "Zoeken en opdrachten worden in een later deelproject ingeschakeld.",
+ "backToSite": "Terug naar de website"
+ },
+ "navigation": {
+ "skipToContent": "Naar inhoud",
+ "primary": "Housekeeping-domeinen",
+ "contextual": "Domeinnavigatie"
+ },
+ "domains": {
+ "operations": {
+ "title": "Operaties",
+ "description": "Operationeel overzicht en dagelijkse wachtrijen"
+ },
+ "people": {
+ "title": "Gebruikers",
+ "description": "Gebruikers, moderatie en ondersteuning"
+ },
+ "content": {
+ "title": "Inhoud",
+ "description": "Redactionele inhoud en betrokkenheid van de community"
+ },
+ "economy": {
+ "title": "Economie",
+ "description": "Catalogus- en winkelbeheer"
+ },
+ "hotel": {
+ "title": "Hotel",
+ "description": "Kamers, radio en hulpmiddelen voor assets"
+ },
+ "system": {
+ "title": "Systeem",
+ "description": "Configuratie, observatie en toegang"
+ }
+ },
+ "states": {
+ "loading": {
+ "title": "Housekeeping laden",
+ "description": "De beschikbare housekeeping-tools worden voorbereid."
+ },
+ "empty": {
+ "title": "Niets beschikbaar",
+ "description": "Er is geen housekeeping-inhoud beschikbaar voor dit domein."
+ },
+ "partial": {
+ "label": "Gedeeltelijke gegevens",
+ "title": "Sommige informatie is niet beschikbaar",
+ "description": "Controleer de beschikbare informatie en probeer het later opnieuw."
+ },
+ "error": {
+ "title": "Housekeeping kan niet worden geladen",
+ "description": "Probeer het later opnieuw of neem contact op met een beheerder."
+ },
+ "forbidden": {
+ "title": "Toegang geweigerd",
+ "description": "Je account heeft geen toestemming om dit housekeeping-onderdeel te gebruiken."
+ },
+ "conflict": {
+ "title": "Wijzigingsconflict",
+ "description": "Deze informatie is tijdens je werk gewijzigd. Laad de gegevens opnieuw voordat je het nogmaals probeert."
+ },
+ "dependency": {
+ "title": "Dienst niet beschikbaar",
+ "description": "Een vereiste housekeeping-dienst is niet beschikbaar. Probeer het later opnieuw."
+ },
+ "notFound": {
+ "title": "Pagina niet gevonden",
+ "description": "Deze housekeeping-pagina bestaat niet of is niet meer beschikbaar."
+ },
+ "success": {
+ "title": "Wijzigingen opgeslagen",
+ "description": "De housekeeping-bewerking is voltooid."
+ },
+ "retry": "Opnieuw proberen",
+ "backToSite": "Terug naar de website",
+ "backToHousekeeping": "Terug naar housekeeping",
+ "supportReference": "Referentie voor ondersteuning: {reference}"
+ }
+ },
"studio": {
"title": "Studio",
"sourceFurnidata": "{source} furnidata",