176 lines
8.9 KiB
TypeScript
176 lines
8.9 KiB
TypeScript
import { lazy, Suspense, type ComponentType } from 'react';
|
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
import { Spinner } from '@astryxdesign/core/Spinner';
|
|
import { RequireRole, useAuth } from '@/auth/AuthContext';
|
|
import { HOME_ROUTE } from '@/auth/roles';
|
|
import { withStaleChunkRecovery } from '@/lib/staleChunk';
|
|
import { LoginPage } from '@/features/auth/LoginPage';
|
|
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
|
|
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
|
|
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
|
|
|
/**
|
|
* Routes are split per page.
|
|
*
|
|
* The reference console ships as one 1.8MB chunk with no route splitting, which
|
|
* means every visitor downloads the spreadsheet importer to look at a dashboard.
|
|
* That is the one thing from it worth deliberately not copying.
|
|
*/
|
|
/**
|
|
* Split pages recover from a deploy that lands mid-session.
|
|
*
|
|
* `withStaleChunkRecovery` is the difference between "Inventory is broken" and
|
|
* a reload nobody notices: the hashed filename this tab remembers stops
|
|
* existing the moment a new build ships, and the import 404s. See
|
|
* `lib/staleChunk.ts` — real errors still surface, only the missing chunk is
|
|
* retried.
|
|
*/
|
|
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
|
|
lazy(withStaleChunkRecovery(() => loader().then((module) => ({ default: module[key] }))));
|
|
|
|
const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pages/StoresPage'));
|
|
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
|
|
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
|
|
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
|
|
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
|
|
|
|
/* One Console for both workspaces — it reads its own scope from BranchScope,
|
|
which pins a store user to their outlet and lets an admin choose. Both routes
|
|
below therefore render the same component, not two copies of one board. */
|
|
const ConsolePage = named('ConsolePage', () => import('@/features/console/ConsolePage'));
|
|
const SalesPage = named('SalesPage', () => import('@/features/store-admin/pages/SalesPage'));
|
|
const InventoryPage = named('InventoryPage', () => import('@/features/store-admin/pages/InventoryPage'));
|
|
const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pages/ReportsPage'));
|
|
const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/store-admin/pages/OnboardBranchPage'));
|
|
const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/UsersPage'));
|
|
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
|
|
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
|
|
const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
|
|
const OnboardingPage = named('OnboardingPage', () => import('@/features/onboarding/OnboardingPage'));
|
|
|
|
/* The Store user workspace reuses the merchant's four pages, pinned to one
|
|
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
|
|
its own file: the store catalogue, its customers, its QR and its account. */
|
|
const StoreProductsPage = named('StoreProductsPage', () => import('@/features/store-user/pages/StoreProductsPage'));
|
|
const StoreCustomersPage = named('StoreCustomersPage', () => import('@/features/store-user/pages/StoreCustomersPage'));
|
|
const StoreStaffPage = named('StoreStaffPage', () => import('@/features/store-user/pages/StoreStaffPage'));
|
|
const StoreAccountPage = named('StoreAccountPage', () => import('@/features/store-user/pages/StoreAccountPage'));
|
|
const StoreUploadsPage = named('StoreUploadsPage', () => import('@/features/store-user/pages/StoreUploadsPage'));
|
|
|
|
function RouteFallback() {
|
|
return (
|
|
<div style={{ display: 'grid', placeItems: 'center', padding: 64 }}>
|
|
<Spinner size="md" label="Loading" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Routes.
|
|
*
|
|
* Three workspaces, hard-partitioned by role and by URL prefix, the same way the
|
|
* three logins imply. Only the Nearle Admin workspace is built so far; the other
|
|
* two resolve to a placeholder rather than a 404, so a Store Admin signing in
|
|
* today lands somewhere that explains itself.
|
|
*/
|
|
export function App() {
|
|
const { user } = useAuth();
|
|
|
|
return (
|
|
<Routes>
|
|
<Route path="/login" element={<LoginPage />} />
|
|
|
|
{/* Nearle Admin — the platform workspace */}
|
|
<Route
|
|
path="/nearle"
|
|
element={
|
|
<RequireRole role="nearle-admin">
|
|
<Suspense fallback={<RouteFallback />}>
|
|
<NearleAdminShell />
|
|
</Suspense>
|
|
</RequireRole>
|
|
}
|
|
>
|
|
<Route index element={<Navigate to="/nearle/stores" replace />} />
|
|
<Route path="stores" element={<StoresPage />} />
|
|
<Route path="stores/:tenantId" element={<StoreDetailPage />} />
|
|
<Route path="onboard/tenant" element={<OnboardTenantPage />} />
|
|
{/* Branch onboarding moved to the Store Admin workspace — the merchant
|
|
opens their own outlets. Kept as a redirect rather than deleted so a
|
|
bookmark or a stale link lands on the directory instead of a 404. */}
|
|
<Route path="onboard/branch" element={<Navigate to="/nearle/stores" replace />} />
|
|
<Route path="catalogue" element={<GlobalCataloguePage />} />
|
|
<Route path="uploads" element={<NearleUploadsPage />} />
|
|
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
|
|
never bounce out to a HOME_ROUTE that points back into this
|
|
workspace and loop. */}
|
|
<Route path="*" element={<Navigate to="/nearle/stores" replace />} />
|
|
</Route>
|
|
|
|
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
|
|
<Route
|
|
path="/admin"
|
|
element={
|
|
<RequireRole role="store-admin">
|
|
<Suspense fallback={<RouteFallback />}>
|
|
<StoreAdminShell />
|
|
</Suspense>
|
|
</RequireRole>
|
|
}
|
|
>
|
|
<Route index element={<Navigate to="/admin/console" replace />} />
|
|
<Route path="console" element={<ConsolePage />} />
|
|
<Route path="sales" element={<SalesPage />} />
|
|
<Route path="inventory" element={<InventoryPage />} />
|
|
<Route path="reports" element={<ReportsPage />} />
|
|
<Route path="branches/new" element={<OnboardBranchPage />} />
|
|
{/* Reached from the account menu, not the nav — setup is not a place
|
|
anyone works from day to day. See `AppShellProps.manageItems`. */}
|
|
<Route path="users" element={<UsersPage />} />
|
|
<Route path="terminals" element={<TerminalsPage />} />
|
|
<Route path="uploads" element={<AdminUploadsPage />} />
|
|
<Route path="profile" element={<ShopProfilePage />} />
|
|
<Route path="onboarding" element={<OnboardingPage />} />
|
|
{/* Catches `/admin/dashboard` and anything else that does not resolve.
|
|
Without this, an unknown sub-path escapes to the global `*`, which
|
|
redirects to this role's HOME_ROUTE — and if that is itself an
|
|
unknown path, the two bounce forever and the page renders blank. */}
|
|
<Route path="*" element={<Navigate to="/admin/console" replace />} />
|
|
</Route>
|
|
|
|
{/* Store user — one branch, fixed. Same four destinations as the
|
|
merchant, and mostly the same pages: what differs is the scope, not
|
|
the shape, so pinning them beats a second set that drifts. */}
|
|
<Route
|
|
path="/store"
|
|
element={
|
|
<RequireRole role="store-manager">
|
|
<Suspense fallback={<RouteFallback />}>
|
|
<StoreUserShell />
|
|
</Suspense>
|
|
</RequireRole>
|
|
}
|
|
>
|
|
<Route index element={<Navigate to="/store/console" replace />} />
|
|
<Route path="console" element={<ConsolePage />} />
|
|
<Route path="products" element={<StoreProductsPage />} />
|
|
<Route path="sales" element={<SalesPage />} />
|
|
<Route path="reports" element={<ReportsPage />} />
|
|
{/* Reached from the account menu. A shop does not commission outlets,
|
|
so there is deliberately no `branches/new` here. */}
|
|
<Route path="customers" element={<StoreCustomersPage />} />
|
|
<Route path="terminals" element={<TerminalsPage />} />
|
|
<Route path="staff" element={<StoreStaffPage />} />
|
|
<Route path="uploads" element={<StoreUploadsPage />} />
|
|
<Route path="account" element={<StoreAccountPage />} />
|
|
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
|
</Route>
|
|
|
|
<Route
|
|
path="*"
|
|
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}
|
|
/>
|
|
</Routes>
|
|
);
|
|
}
|