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: 5 additions & 1 deletion .claude/rules/frontend/build-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,13 @@ federation remotes. Understand these constraints before changing any `rslib.conf
hand-rolled copy step). Node-side build code must be ESM-safe (`import.meta.dirname`,
not `__dirname`; read JSON with `fs`, not `require()`).

4. **Keep `react`, `react-dom`, `@lingui/core`, `@lingui/react` as module-federation `shared` singletons**
4. **Keep `react`, `react-dom`, `@lingui/core`, `@lingui/react`, `@tanstack/react-router`, and
`@tanstack/react-query` as module-federation `shared` singletons**
(`application/shared-webapp/build/plugin/ModuleFederationPlugin.ts`). The translation system depends on a
single shared Lingui `i18n` across remotes — see [translations](/.claude/rules/frontend/translations.md).
Router instances and route objects cross the federation boundary (account contributes its route subtree
to Main's single router), and federated components resolve their QueryClient from the host's provider,
so those modules must also bind to one instance.

5. **TypeScript 7 is intentionally deferred — stay on the 6.x line.** TS 7's native compiler removed the
classic programmatic API (its `package.json` exposes no main export, only `./unstable/*`), so
Expand Down
6 changes: 5 additions & 1 deletion .claude/rules/frontend/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@ Use browser MCP tools to test at `https://app.dev.localhost:<appGateway>`. Look
2. **Module Federation for Micro-Frontends**:
- Each self-contained system has its own WebApp
- Common UI exposed via federation in `federated-modules/`
- **Federated leaf exposes stay presentation-shaped**: they may navigate through the shared router hooks, but they never own routes, history, or shared DOM regions. The only structural expose is `./routes` (the route subtree contribution)
- Shared components in `application/shared-webapp/`
- Don't import directly between self-contained systems

3. **Navigation**:
- **One router per page**: The Main host runs the only TanStack Router. Federated systems contribute route subtrees to it (account exposes `./routes`; Main grafts it in `shared/lib/router/router.tsx`). Never create a second router in a federated module - lint blocks `createRouter` outside `shared/lib/router/router.tsx`
- **The router owns browser history**: never call `window.history.pushState`/`replaceState` - lint blocks both. Hand-rolled history sync is a shadow routing pattern that desynchronizes the router from the address bar
- **Within a self-contained system**: Use `useNavigate()` hook or `<Link>` component from TanStack Router
- **Account to Main**: Account runs as a federated module inside Main with a memory router. Use the `useMainNavigation()` hook (backed by `MainNavigationContext`) to navigate back to Main, not `window.location.href`
- **Account to Main**: Use the `useMainNavigation()` hook. Main paths such as `/dashboard` are ordinary navigations on the shared router; they are just not part of account's typed route tree
- **To Back-Office**: Back-Office is a separate SPA, so use `window.location.href` for full-page navigation
- Only use `window.location.href` when navigating to a different SPA or for full-page reloads (e.g., logout)
- **Route protection**: use `beforeLoad` only with the `routeGuards` helpers (`requireAuthentication`, `requirePermission`, `requireSubscriptionEnabled`) or tiny context flags like `disableAuthSync`. Prefer declarative guards that render `<Navigate>` (e.g. `OnboardingGuard`) for user-state redirects. Never load data in `beforeLoad` or route loaders - server state lives in TanStack Query only

4. **API Integration**:
- API client auto-generated from OpenAPI spec
Expand Down
30 changes: 30 additions & 0 deletions application/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",

// The router owns browser history. Direct history mutation is a shadow routing pattern that
// desynchronizes the router from the address bar.
"no-restricted-properties": [
"error",
{
"property": "pushState",
"message": "Never manipulate window.history directly; the single router owns history. Navigate with useNavigate() or <Link>."
},
{
"property": "replaceState",
"message": "Never manipulate window.history directly; the single router owns history. Navigate with useNavigate() or <Link>."
}
],

// Restrict specific imports with custom messages
"no-restricted-imports": [
"error",
Expand All @@ -95,6 +109,16 @@
{
"name": "recharts",
"message": "Import chart components from @repo/ui/components/Chart instead. The shared wrappers default accessibilityLayer={true} on chart types so Tab routes to data points and the focus ring matches our theme — importing recharts directly bypasses this and Chrome will paint its native blue focus ring on the SVG."
},
{
"name": "@tanstack/react-router",
"importNames": ["createRouter"],
"message": "Only shared/lib/router/router.tsx may create a router. Exactly one router owns history and rendering per page; federated modules contribute route subtrees to the host router and must never mount a router of their own."
},
{
"name": "react-dom",
"importNames": ["createPortal"],
"message": "Do not portal into shared DOM nodes. Render into a container owned by a single component (e.g. BannerContainer children) so every DOM region has exactly one writer."
}
]
}
Expand Down Expand Up @@ -173,6 +197,12 @@
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["**/main/WebApp/shared/lib/router/router.tsx", "**/BackOffice/shared/lib/router/router.tsx"],
"rules": {
"no-restricted-imports": "off"
}
}
]
}
11 changes: 5 additions & 6 deletions application/account/BackOffice/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { AuthenticationProvider } from "@repo/infrastructure/auth/Authentication
import { themeColor } from "@repo/infrastructure/branding";
import { useErrorTrigger } from "@repo/infrastructure/development/useErrorTrigger";
import { useInitializeLocale } from "@repo/infrastructure/translations/useInitializeLocale";
import { BannerPortal } from "@repo/ui/components/BannerPortal";
import { BannerContainer } from "@repo/ui/components/BannerContainer";
import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode";
import { QueryClientProvider } from "@tanstack/react-query";
import { createRootRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { createRootRoute, Outlet } from "@tanstack/react-router";

import { BackOfficeBanners } from "@/shared/components/BackOfficeBanners";
import { ErrorPage } from "@/shared/components/errorPages/ErrorPage";
Expand All @@ -20,17 +20,16 @@ export const Route = createRootRoute({
});

function Root() {
const navigate = useNavigate();
useInitializeLocale();
useErrorTrigger();

return (
<QueryClientProvider client={queryClient}>
<ThemeModeProvider themeColor={themeColor}>
<AuthenticationProvider navigate={(options) => navigate(options)}>
<BannerPortal>
<AuthenticationProvider>
<BannerContainer>
<BackOfficeBanners />
</BannerPortal>
</BannerContainer>
<PageTracker />
<Outlet />
</AuthenticationProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { Button } from "@repo/ui/components/Button";
import { Separator } from "@repo/ui/components/Separator";
import { AlertCircleIcon, FlagIcon, InfoIcon, TriangleAlertIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";

import { ItemPreview } from "./ItemPreview";
import { ProgressPreview } from "./ProgressPreview";
Expand All @@ -29,14 +28,13 @@ const bannerContent: Record<BannerVariant, { message: () => string; icon: React.
}
};

// Renders inline in the preview. The live banner area is owned exclusively by BannerContainer,
// so preview content must never render into it.
function SampleBanner({ variant, onClose }: Readonly<{ variant: BannerVariant; onClose: () => void }>) {
const [target] = useState(() => document.getElementById("banner-root"));
if (!target) return null;

const content = bannerContent[variant];

return createPortal(
<div className="flex h-12 items-center gap-3 border-b border-warning/50 bg-warning px-4 text-sm">
return (
<div className="flex h-12 items-center gap-3 rounded-md border border-warning/50 bg-warning px-4 text-sm">
{content.icon}
<span className="flex-1 text-warning-foreground">{content.message()}</span>
{variant === "cta" && (
Expand All @@ -49,8 +47,7 @@ function SampleBanner({ variant, onClose }: Readonly<{ variant: BannerVariant; o
<XIcon className="size-4" />
</Button>
)}
</div>,
target
</div>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,15 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";

import { BillingDriftBanner } from "./BillingDriftBanner";
import { MrrMismatchBanner } from "./MrrMismatchBanner";
import { UnsyncedAccountsBanner } from "./UnsyncedAccountsBanner";

/**
* Portals all back-office banners into the fixed-top BannerPortal target so they render above the
* sidebar and content rather than being clipped by the layout. The user-facing Banners federated
* module relies on a lazy boundary to defer mount until BannerPortal's DOM is committed; we render
* synchronously, so the target lookup runs in useEffect to avoid the first-render race.
*/
// Rendered as a child of BannerContainer, which owns the fixed banner area.
// Banners are ordinary React children of that container so the container has exactly one writer.
export function BackOfficeBanners() {
const [target, setTarget] = useState<HTMLElement | null>(null);

useEffect(() => {
setTarget(document.getElementById("banner-root"));
}, []);

if (!target) {
return null;
}

return createPortal(
return (
<>
<UnsyncedAccountsBanner />
<MrrMismatchBanner />
<BillingDriftBanner />
</>,
target
</>
);
}
94 changes: 0 additions & 94 deletions application/account/WebApp/federated-modules/AccountApp.tsx

This file was deleted.

16 changes: 4 additions & 12 deletions application/account/WebApp/federated-modules/banners/Banners.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,19 @@
import { useState } from "react";
import { createPortal } from "react-dom";

import { withAccountTranslations } from "@/shared/translations/withAccountTranslations";

import ExpiringCardBanner from "./ExpiringCardBanner";
import InvitationBanner from "./InvitationBanner";
import PaymentFailedBanner from "./PaymentFailedBanner";
import "@repo/ui/tailwind.css";

// Rendered as a child of the host's BannerContainer, which owns the fixed banner area.
// Banners are ordinary React children of that container so the container has exactly one writer.
function Banners() {
const [target] = useState(() => document.getElementById("banner-root"));

if (!target) {
return null;
}

return createPortal(
return (
<>
<InvitationBanner />
<PaymentFailedBanner />
<ExpiringCardBanner />
</>,
target
</>
);
}

Expand Down
52 changes: 52 additions & 0 deletions application/account/WebApp/federated-modules/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { AuthenticationProvider } from "@repo/infrastructure/auth/AuthenticationProvider";
import { type AnyRoute, createRoute, Outlet } from "@tanstack/react-router";

import { withAccountTranslations } from "@/shared/translations/withAccountTranslations";

import { routeTree } from "../shared/lib/router/routeTree.generated";
import ErrorPage from "./errorPages/ErrorPage";
import NotFoundPage from "./errorPages/NotFoundPage";
import "@repo/ui/tailwind.css";

// The #account element scopes account CSS. The AuthenticationProvider must come from account's
// own bundle because @repo packages are compiled per system and context objects are not shared
// across the federation boundary.
function AccountLayout() {
return (
<div id="account" className="contents">
<AuthenticationProvider>
<Outlet />
</AuthenticationProvider>
</div>
);
}

// The public update() type omits getParentRoute, but re-parenting through update() is exactly
// how the route generator itself assembles the tree.
type ReparentableRoute = AnyRoute & {
update: (options: { getParentRoute: () => AnyRoute }) => unknown;
};

/**
* Account's contribution to the host's single router: a pathless layout route carrying the
* #account style scope, the account translation catalog gate, and account's error pages, with
* every generated account route re-parented beneath it.
*
* The generated tree's own root exists only as a code generation anchor and never renders.
*/
export default function createAccountRouteTree(getParentRoute: () => AnyRoute): AnyRoute {
const accountLayoutRoute = createRoute({
id: "account-layout",
getParentRoute,
component: withAccountTranslations(AccountLayout),
errorComponent: ErrorPage,
notFoundComponent: NotFoundPage
});

const accountRoutes = (routeTree.children ?? []) as ReparentableRoute[];
for (const route of accountRoutes) {
route.update({ getParentRoute: () => accountLayoutRoute });
}

return accountLayoutRoute.addChildren(accountRoutes);
}
Loading
Loading