Apply the tier gating to routes and navigation on top of the #298 UI guard: - App.tsx: pathless RequireFeature layout-routes grouping /balance, /balance/accounts, /balance/snapshot under "balance"; /reports/ highlights|compare|category|cartes under "reports-advanced"; /budget under "budget"; /adjustments under "adjustments". The /reports hub and /reports/trends stay Free and ungated. - NavItem gains an optional `feature?: FeatureKey`; set in NAV_ITEMS on budget, adjustments and balance only — NOT on reports (Free hub). - Sidebar: local NavLock child component (hook at component top level) renders a lock badge only when the license is ready AND the feature is not allowed — no locked flash at boot; items stay clickable and lead to the upsell via the gated route. Tooltip/aria reuse nav.locked. - ReportsPage hub: single useEntitlement("reports-advanced") call drives a `locked` badge on the 4 advanced tiles via a new additive HubReportNavCard `locked?` prop; the Trends tile is never locked. - Pure contract test on NAV_ITEMS (gated trio present, reports/Free items ungated, exactly 3 of 9 gated). No new i18n strings (nav.locked shipped with #298), no DB migration. Changelog centralized in #302. Resolves #299 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
159 lines
6.7 KiB
TypeScript
159 lines
6.7 KiB
TypeScript
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|
import { useEffect, useState, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useProfile } from "./contexts/ProfileContext";
|
|
import AppShell from "./components/layout/AppShell";
|
|
import DashboardPage from "./pages/DashboardPage";
|
|
import ImportPage from "./pages/ImportPage";
|
|
import TransactionsPage from "./pages/TransactionsPage";
|
|
import CategoriesPage from "./pages/CategoriesPage";
|
|
import AdjustmentsPage from "./pages/AdjustmentsPage";
|
|
import BudgetPage from "./pages/BudgetPage";
|
|
import ReportsPage from "./pages/ReportsPage";
|
|
import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
|
|
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
|
|
import ReportsComparePage from "./pages/ReportsComparePage";
|
|
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
|
import SettingsLayout from "./pages/settings/SettingsLayout";
|
|
import SettingsHomePage from "./pages/settings/SettingsHomePage";
|
|
import UsersSettingsPage from "./pages/settings/UsersSettingsPage";
|
|
import DataSettingsPage from "./pages/settings/DataSettingsPage";
|
|
import SystemsSettingsPage from "./pages/settings/SystemsSettingsPage";
|
|
import AccountsPage from "./pages/AccountsPage";
|
|
import BalancePage from "./pages/BalancePage";
|
|
import SnapshotEditPage from "./pages/SnapshotEditPage";
|
|
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
|
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
|
import DocsPage from "./pages/DocsPage";
|
|
import ChangelogPage from "./pages/ChangelogPage";
|
|
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
|
import ErrorPage from "./components/shared/ErrorPage";
|
|
import RequireFeature from "./components/shared/RequireFeature";
|
|
|
|
const STARTUP_TIMEOUT_MS = 10_000;
|
|
const MAX_RETRIES = 3;
|
|
const RETRY_DELAY_MS = 1_000;
|
|
|
|
export default function App() {
|
|
const { t } = useTranslation();
|
|
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
|
const [dbReady, setDbReady] = useState(false);
|
|
const [startupError, setStartupError] = useState<string | null>(null);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const cancelledRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (activeProfile && !isLoading) {
|
|
setDbReady(false);
|
|
setStartupError(null);
|
|
cancelledRef.current = false;
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
setStartupError(t("error.startupTimeout"));
|
|
}, STARTUP_TIMEOUT_MS);
|
|
|
|
const attemptConnect = async (attempt: number): Promise<void> => {
|
|
try {
|
|
await connectActiveProfile();
|
|
if (cancelledRef.current) return;
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
setDbReady(true);
|
|
} catch (err) {
|
|
if (cancelledRef.current) return;
|
|
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
|
|
if (attempt < MAX_RETRIES) {
|
|
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
if (!cancelledRef.current) return attemptConnect(attempt + 1);
|
|
} else {
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
setStartupError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
};
|
|
|
|
attemptConnect(1);
|
|
}
|
|
|
|
return () => {
|
|
cancelledRef.current = true;
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
};
|
|
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen bg-[var(--background)]">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (startupError) {
|
|
return <ErrorPage error={startupError} />;
|
|
}
|
|
|
|
if (!activeProfile) {
|
|
return <ProfileSelectionPage />;
|
|
}
|
|
|
|
if (!dbReady) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen bg-[var(--background)]">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<BrowserRouter key={refreshKey}>
|
|
<Routes>
|
|
<Route element={<AppShell />}>
|
|
<Route path="/" element={<DashboardPage />} />
|
|
<Route path="/import" element={<ImportPage />} />
|
|
<Route path="/transactions" element={<TransactionsPage />} />
|
|
<Route path="/categories" element={<CategoriesPage />} />
|
|
{/* Gated routes (soft paywall): pathless layout-routes render
|
|
RequireFeature's <Outlet/> — one group per feature, mirroring the
|
|
SettingsLayout convention. The /reports hub and /reports/trends
|
|
stay Free and OUTSIDE any gate. */}
|
|
<Route element={<RequireFeature feature="adjustments" />}>
|
|
<Route path="/adjustments" element={<AdjustmentsPage />} />
|
|
</Route>
|
|
<Route element={<RequireFeature feature="budget" />}>
|
|
<Route path="/budget" element={<BudgetPage />} />
|
|
</Route>
|
|
<Route path="/reports" element={<ReportsPage />} />
|
|
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
|
|
<Route element={<RequireFeature feature="reports-advanced" />}>
|
|
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
|
|
<Route path="/reports/compare" element={<ReportsComparePage />} />
|
|
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
|
</Route>
|
|
<Route path="/settings" element={<SettingsLayout />}>
|
|
<Route index element={<SettingsHomePage />} />
|
|
<Route path="users" element={<UsersSettingsPage />} />
|
|
<Route path="data" element={<DataSettingsPage />} />
|
|
<Route path="systems" element={<SystemsSettingsPage />} />
|
|
</Route>
|
|
<Route element={<RequireFeature feature="balance" />}>
|
|
<Route path="/balance" element={<BalancePage />} />
|
|
<Route path="/balance/accounts" element={<AccountsPage />} />
|
|
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
|
</Route>
|
|
<Route
|
|
path="/settings/categories/standard"
|
|
element={<CategoriesStandardGuidePage />}
|
|
/>
|
|
<Route
|
|
path="/settings/categories/migrate"
|
|
element={<CategoriesMigrationPage />}
|
|
/>
|
|
<Route path="/docs" element={<DocsPage />} />
|
|
<Route path="/changelog" element={<ChangelogPage />} />
|
|
</Route>
|
|
</Routes>
|
|
</BrowserRouter>
|
|
);
|
|
}
|