From 553da0ce8c2b1dba845180c32343d4c1d8cc3cae Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 20 Jul 2026 22:12:51 -0400 Subject: [PATCH] feat(gating): gate routes and Sidebar for budget, advanced reports, balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 31 ++++++++++++----- src/components/layout/Sidebar.tsx | 29 ++++++++++++++++ src/components/reports/HubReportNavCard.tsx | 29 ++++++++++++++-- src/pages/ReportsPage.tsx | 9 +++++ src/shared/constants/index.test.ts | 37 +++++++++++++++++++++ src/shared/constants/index.ts | 3 ++ src/shared/types/index.ts | 8 +++++ 7 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 src/shared/constants/index.test.ts 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 ---