import { Suspense, lazy } from "react" import { Navigate, useLocation, type RouteObject } from "react-router" import { LoadingFallback } from "./pages/_loading" import { RouteError } from "./pages/_error" import { LibraryLayout } from "./pages/library/_layout" import ProductDashboard from "./pages/_product-dashboard" import { ProductRootGate } from "@/contexts/product-root-gate" import { DefaultProductRedirect } from "@/contexts/default-product-redirect" import { TENANT_PRODUCT_SHELL_HUB_SEGMENTS } from "@exxatdesignux/product-framework" import { PRISM_HUB_SEGMENTS, ONE_SITES_HUB_SEGMENTS } from "@/lib/mock/navigation" import { getStorageItem } from "@exxatdesignux/ui/lib/persisted-state" /** Per-device flag set when the builder onboarding flow completes. */ const ONBOARDING_COMPLETE_KEY = "builder:onboarding-complete:v1" /** * Vite route table — implements the multi-product routing pattern * (`apps/web/docs/multi-product-routing-pattern.md`): * * - **Per-product dashboards** live under `//dashboard` * (Rule 1). Switching products navigates to that root's dashboard * (Rule 2 — driven by `useProductSwitch` in * `contexts/product-route-sync.tsx`). * - **Back-compat redirects** at the workspace root (`/dashboard` → * `/prism/dashboard`) keep deep links from before this refactor working * without a server-side 308. * - **Library** mounts under product roots (`//library/*`). * The legacy root `/library/*` mount stays as a back-compat alias while * the Library subsystem normalizes product prefixes internally. * - **Shell-global settings** — profile prefs at `/settings/profile`; * org/workspace (products) at `/settings/organization` and * `//settings`. `/settings` redirects for back-compat. * - **`/help`** stays shell-global (per *user*, not per *product*). * - **DS demo routes** (`/columns`, `/tokens-themes`) also stay at the * root while they're still development surfaces — same exception. */ /** Back-compat: `/library`, `/columns`, `/tokens-themes`, `/exam` → Design OS product root. */ function RedirectToDesignOsSubpath({ fromPrefix, toPrefix }: { fromPrefix: string; toPrefix: string }) { const location = useLocation() const rest = location.pathname.startsWith(fromPrefix) ? location.pathname.slice(fromPrefix.length) : "" const normalizedRest = rest.startsWith("/") ? rest : rest ? `/${rest}` : "" return ( ) } function lazyPage( loader: () => Promise<{ default: React.ComponentType }>, fallback: React.ReactNode = , ) { const Lazy = lazy(loader) return ( ) } /** * Back-compat redirect for `/dashboard` → default product dashboard. */ function LegacyRedirectToDefaultDashboard() { return } // First-visit landing — run the builder onboarding flow on the very first // visit, then land on the products home on every subsequent load. // Onboarding marks itself complete via `builder:onboarding-complete:v1` // (set in `views/builder-onboarding.tsx`), so the redirect only fires once. // Re-run it from `Settings → Profile → Restart onboarding` (which clears the // flag) or by visiting `/builder/onboarding` directly. // // `/` used to drop straight into the default product's dashboard. It now stops // at `/home` so someone with more than one product picks rather than getting // dealt one — `/dashboard` still shortcuts to the default for anyone who // bookmarked it. function FirstRunRedirect() { const onboardingComplete = getStorageItem(ONBOARDING_COMPLETE_KEY) === "true" if (!onboardingComplete) { return } return } // ───────────────────────────────────────────────────────────────────────────── // Per-product children // ───────────────────────────────────────────────────────────────────────────── // // Today only the dashboard is under a product root. Once Library / Placements // / Settings sub-screens move under products, append their children here (or // extract per-product factories like `prismChildren()`, `oneSitesChildren()`). const TENANT_PRODUCT_SHELL_HUBS = TENANT_PRODUCT_SHELL_HUB_SEGMENTS.filter( segment => segment !== "library", ) /** Prism + One — Schools + One — Sites + legacy tenant shell hubs. */ /** * Nav segments that have a real page and must not fall through to * `_product-shell-placeholder`. Keep in sync with the explicit routes in * `productChildren()`. */ const BUILT_HUB_SEGMENTS = new Set([ "learning-activities", "locations", "slot-requests", ]) const PRODUCT_SHELL_HUB_SEGMENTS = [ ...new Set([ ...PRISM_HUB_SEGMENTS, ...ONE_SITES_HUB_SEGMENTS, ...TENANT_PRODUCT_SHELL_HUBS, ]), ] function libraryChildren(): RouteObject[] { return [ { index: true, element: lazyPage(() => import("./pages/library/index")) }, { path: "all", element: lazyPage(() => import("./pages/library/all")) }, { path: "find", element: lazyPage(() => import("./pages/library/find")) }, { path: "list", element: lazyPage(() => import("./pages/library/list")) }, { path: "new", element: lazyPage(() => import("./pages/library/new")) }, ] } function learningActivitiesChildren(): RouteObject[] { return [ { index: true, element: lazyPage(() => import("./pages/learning-activities")) }, { path: "courses/:offeringId", element: lazyPage(() => import("./pages/learning-activities-course-detail")), }, ] } function productChildren(): RouteObject[] { return [ { index: true, element: }, { path: "dashboard", element: , errorElement: , }, { path: "library", element: , children: libraryChildren(), errorElement: , }, { path: "leo", element: lazyPage(() => import("./pages/leo")), errorElement: , }, { path: "design-system", element: lazyPage(() => import("./pages/design-system/_layout")), errorElement: , children: [ { index: true, element: lazyPage(() => import("./pages/design-system/index")) }, { path: ":slug", element: lazyPage(() => import("./pages/design-system/doc")) }, ], }, { path: "columns", element: lazyPage(() => import("./pages/columns")), errorElement: , }, { path: "column-types-demo", element: lazyPage(() => import("./pages/column-types-demo")), errorElement: , }, { path: "tokens-themes", element: lazyPage(() => import("./pages/tokens-themes")), errorElement: , }, { path: "focus-workflow", element: lazyPage(() => import("./pages/focus-workflow")), errorElement: , }, { path: "exam-lock", element: lazyPage(() => import("./pages/exam-lock")), errorElement: , }, { path: "learning-activities", element: lazyPage(() => import("./pages/learning-activities/_layout")), children: learningActivitiesChildren(), errorElement: , }, // Exxat One hubs. These four are real surfaces now, so they must be // declared before the placeholder catch-all below and excluded from it. { path: "locations", element: lazyPage(() => import("./pages/one/locations")), errorElement: , }, { path: "slot-requests", element: lazyPage(() => import("./pages/one/slot-requests")), errorElement: , }, ...PRODUCT_SHELL_HUB_SEGMENTS.filter( segment => !BUILT_HUB_SEGMENTS.has(segment), ).map(segment => ({ path: `${segment}/*`, element: lazyPage(() => import("./pages/_product-shell-placeholder")), errorElement: , })), { path: "settings", element: lazyPage(() => import("./pages/settings-organization")), errorElement: , }, ] } export const routes: RouteObject[] = [ // ─────────────────────────────────────────────────────────────────────── // Root + back-compat redirects // ─────────────────────────────────────────────────────────────────────── // Cold-start landing → onboarding on the first visit, then the products home. { index: true, element: }, // Pre-product back-compat for the only previously-rooted hub. { path: "dashboard", element: }, // ─────────────────────────────────────────────────────────────────────── // Products home — shell-global. The marketing pages live here rather than // under a product root because their audience is people without that // product; routing them through `/prism/*` would adopt a product context // they are not entitled to. // ─────────────────────────────────────────────────────────────────────── { path: "home", errorElement: , children: [ { index: true, element: lazyPage(() => import("./pages/product-home")) }, { path: ":productSlug", element: lazyPage(() => import("./pages/product-marketing")), }, ], }, // ─────────────────────────────────────────────────────────────────────── // Shell-global routes (per-user, not per-product) // ─────────────────────────────────────────────────────────────────────── { path: "library/*", element: , }, { path: "settings", element: lazyPage(() => import("./pages/settings-layout")), errorElement: , children: [ { index: true, element: lazyPage(() => import("./pages/settings-legacy-redirect")), }, { path: "profile", element: lazyPage(() => import("./pages/settings-profile")), }, { path: "organization", element: lazyPage(() => import("./pages/settings-organization")), }, ], }, { path: "builder/onboarding", element: lazyPage(() => import("./pages/builder-onboarding")), errorElement: , }, { path: "builder/products", element: , }, { path: "help", element: lazyPage(() => import("./pages/help")), errorElement: , }, // ─────────────────────────────────────────────────────────────────────── // DS demo routes (development surfaces — still at root per the rule's // exception; once promoted into a product hub they adopt that subpath). // ─────────────────────────────────────────────────────────────────────── { path: "design-system/*", element: ( ), }, { path: "columns/*", element: , }, { path: "tokens-themes/*", element: ( ), }, { path: "exam/*", element: , }, // ─────────────────────────────────────────────────────────────────────── // Directory products — People, Courses, Personnel (separate roots), plus // Administrator overview. Legacy `/admin/{hub}` paths redirect to the new // product roots so bookmarks keep working. // ─────────────────────────────────────────────────────────────────────── { path: "people", errorElement: , children: [ { index: true, element: }, { path: "students", element: lazyPage(() => import("./pages/admin/people-students")) }, { path: "faculty", element: lazyPage(() => import("./pages/admin/people-faculty")) }, { path: "staff", element: lazyPage(() => import("./pages/admin/people-staff")) }, { path: ":personId", element: lazyPage(() => import("./pages/admin/person-detail")), }, { path: "dashboard", element: }, ], }, { path: "courses", errorElement: , children: [ { index: true, element: lazyPage(() => import("./pages/admin/courses")) }, { path: ":courseId", element: lazyPage(() => import("./pages/admin/course-detail")), }, { path: "dashboard", element: }, ], }, { path: "personnel", errorElement: , children: [ { index: true, element: lazyPage(() => import("./pages/admin/personnel")) }, { path: ":personnelId", element: lazyPage(() => import("./pages/admin/personnel-detail")), }, { path: "dashboard", element: }, ], }, { path: "admin", errorElement: , children: [ { index: true, element: lazyPage(() => import("./pages/admin/index")) }, { path: "people", element: }, { path: "people/*", element: ( ), }, { path: "courses", element: }, { path: "courses/*", element: ( ), }, { path: "personnel", element: }, { path: "personnel/*", element: ( ), }, { path: "dashboard", element: }, ], }, // ─────────────────────────────────────────────────────────────────────── // Product-owned roots (Rule 1) — built-in slugs + custom suffix slugs. // MUST stay after shell-global routes so `/settings` etc. are not captured. // ─────────────────────────────────────────────────────────────────────── { path: ":productRootSegment", element: , children: productChildren(), errorElement: , }, { path: "*", element: lazyPage(() => import("./pages/_not-found")), }, ]