diff --git a/src/App.tsx b/src/App.tsx
index d20737b..4b81f56 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -29,6 +29,7 @@ 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;
@@ -112,23 +113,35 @@ export default function App() {
} />
} />
} />
- } />
- } />
+ {/* Gated routes (soft paywall): pathless layout-routes render
+ RequireFeature's — one group per feature, mirroring the
+ SettingsLayout convention. The /reports hub and /reports/trends
+ stay Free and OUTSIDE any gate. */}
+ }>
+ } />
+
+ }>
+ } />
+
} />
- } />
} />
- } />
- } />
- } />
+ }>
+ } />
+ } />
+ } />
+ } />
+
}>
} />
} />
} />
} />
- } />
- } />
- } />
+ }>
+ } />
+ } />
+ } />
+
}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 6dc19a5..1e1d019 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -11,11 +11,14 @@ import {
Wallet,
Settings,
Languages,
+ Lock,
Moon,
Sun,
} from "lucide-react";
import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
+import { useEntitlement } from "../../hooks/useEntitlement";
import { useTheme } from "../../hooks/useTheme";
+import type { FeatureKey } from "../../shared/entitlements";
import ProfileSwitcher from "../profile/ProfileSwitcher";
const iconMap: Record> = {
@@ -30,6 +33,31 @@ const iconMap: Record> = {
Settings,
};
+/**
+ * Lock badge next to a gated nav item. Rendered as a child component so the
+ * useEntitlement hook runs at a component top level (never inside the
+ * NAV_ITEMS.map callback). Shown ONLY when the license is `ready` AND the
+ * feature is not allowed — never during boot, so a paying user sees no
+ * "locked" flash. The item itself stays clickable (route shows the upsell).
+ */
+function NavLock({ feature }: { feature: FeatureKey }) {
+ const { t } = useTranslation();
+ const { allowed, ready } = useEntitlement(feature);
+
+ if (!ready || allowed) return null;
+
+ return (
+
+
+
+ );
+}
+
export default function Sidebar() {
const { t, i18n } = useTranslation();
const { theme, toggleTheme } = useTheme();
@@ -64,6 +92,7 @@ export default function Sidebar() {
>
{Icon && }
{t(item.labelKey)}
+ {item.feature && }
);
})}
diff --git a/src/components/reports/HubReportNavCard.tsx b/src/components/reports/HubReportNavCard.tsx
index def94e1..b7074d6 100644
--- a/src/components/reports/HubReportNavCard.tsx
+++ b/src/components/reports/HubReportNavCard.tsx
@@ -1,19 +1,44 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
+import { Lock } from "lucide-react";
+import { useTranslation } from "react-i18next";
export interface HubReportNavCardProps {
to: string;
icon: ReactNode;
title: string;
description: string;
+ /**
+ * Show a lock badge on the card (gated feature, license ready and not
+ * entitled). The card stays clickable — the gated route renders the upsell.
+ */
+ locked?: boolean;
}
-export default function HubReportNavCard({ to, icon, title, description }: HubReportNavCardProps) {
+export default function HubReportNavCard({
+ to,
+ icon,
+ title,
+ description,
+ locked,
+}: HubReportNavCardProps) {
+ const { t } = useTranslation();
+
return (
+ {locked && (
+
+
+
+ )}
{icon}
{title}
diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx
index 66b5f72..0057354 100644
--- a/src/pages/ReportsPage.tsx
+++ b/src/pages/ReportsPage.tsx
@@ -4,6 +4,7 @@ import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
import HubReportNavCard from "../components/reports/HubReportNavCard";
+import { useEntitlement } from "../hooks/useEntitlement";
import { useHighlights } from "../hooks/useHighlights";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
@@ -11,6 +12,10 @@ export default function ReportsPage() {
const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { data, isLoading, error } = useHighlights();
+ // Advanced-reports lock badge: shown only when the license is ready AND not
+ // entitled (no "locked" flash at boot). Trends stays Free — never locked.
+ const { allowed: advancedAllowed, ready: licenseReady } = useEntitlement("reports-advanced");
+ const advancedLocked = licenseReady && !advancedAllowed;
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const navCards = [
@@ -19,6 +24,7 @@ export default function ReportsPage() {
icon: ,
title: t("reports.hub.highlights"),
description: t("reports.hub.highlightsDescription"),
+ locked: advancedLocked,
},
{
to: `/reports/trends${preserveSearch}`,
@@ -31,18 +37,21 @@ export default function ReportsPage() {
icon: ,
title: t("reports.hub.compare"),
description: t("reports.hub.compareDescription"),
+ locked: advancedLocked,
},
{
to: `/reports/category${preserveSearch}`,
icon: ,
title: t("reports.hub.categoryZoom"),
description: t("reports.hub.categoryZoomDescription"),
+ locked: advancedLocked,
},
{
to: `/reports/cartes${preserveSearch}`,
icon: ,
title: t("reports.hub.cartes"),
description: t("reports.hub.cartesDescription"),
+ locked: advancedLocked,
},
];
diff --git a/src/shared/constants/index.test.ts b/src/shared/constants/index.test.ts
new file mode 100644
index 0000000..b1d586c
--- /dev/null
+++ b/src/shared/constants/index.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "vitest";
+import { NAV_ITEMS } from "./index";
+
+/**
+ * Nav gating contract (#299). Locks in the /review-spec CRITICAL caveat:
+ * the `reports` nav item points to the FREE hub (/reports, not route-gated)
+ * and must NEVER carry a `feature` — gating it would show a lock over a
+ * fully functional free page.
+ */
+describe("NAV_ITEMS feature gating", () => {
+ const byKey = Object.fromEntries(NAV_ITEMS.map((item) => [item.key, item]));
+
+ it("gates budget, adjustments and balance with their matching feature key", () => {
+ expect(byKey.budget.feature).toBe("budget");
+ expect(byKey.adjustments.feature).toBe("adjustments");
+ expect(byKey.balance.feature).toBe("balance");
+ });
+
+ it("never gates the reports hub nav item nor any Free module", () => {
+ expect(byKey.reports.feature).toBeUndefined();
+ expect(byKey.dashboard.feature).toBeUndefined();
+ expect(byKey.import.feature).toBeUndefined();
+ expect(byKey.transactions.feature).toBeUndefined();
+ expect(byKey.categories.feature).toBeUndefined();
+ expect(byKey.settings.feature).toBeUndefined();
+ });
+
+ it("gates exactly 3 of the 9 nav items", () => {
+ const gated = NAV_ITEMS.filter((item) => item.feature !== undefined);
+ expect(NAV_ITEMS).toHaveLength(9);
+ expect(gated.map((item) => item.key).sort()).toEqual([
+ "adjustments",
+ "balance",
+ "budget",
+ ]);
+ });
+});
diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts
index 9c9e612..151d861 100644
--- a/src/shared/constants/index.ts
+++ b/src/shared/constants/index.ts
@@ -33,12 +33,14 @@ export const NAV_ITEMS: NavItem[] = [
path: "/adjustments",
icon: "SlidersHorizontal",
labelKey: "nav.adjustments",
+ feature: "adjustments",
},
{
key: "budget",
path: "/budget",
icon: "PiggyBank",
labelKey: "nav.budget",
+ feature: "budget",
},
{
key: "reports",
@@ -51,6 +53,7 @@ export const NAV_ITEMS: NavItem[] = [
path: "/balance",
icon: "Wallet",
labelKey: "nav.balance",
+ feature: "balance",
},
{
key: "settings",
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts
index 4297d78..d2c36e4 100644
--- a/src/shared/types/index.ts
+++ b/src/shared/types/index.ts
@@ -1,3 +1,5 @@
+import type { FeatureKey } from "../entitlements";
+
export interface ImportSource {
id: number;
name: string;
@@ -177,6 +179,12 @@ export interface NavItem {
path: string;
icon: string;
labelKey: string;
+ /**
+ * Gated feature backing this nav entry. When set, the Sidebar shows a lock
+ * icon if the license is not entitled (and ready) — the item stays clickable
+ * (the gated route renders the upsell). Absent = never gated (Free module).
+ */
+ feature?: FeatureKey;
}
// --- Import Wizard Types ---