feat(gating): gate routes and Sidebar for budget, advanced reports, balance
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>
This commit is contained in:
parent
554373e7d8
commit
553da0ce8c
7 changed files with 135 additions and 11 deletions
31
src/App.tsx
31
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() {
|
|||
<Route path="/import" element={<ImportPage />} />
|
||||
<Route path="/transactions" element={<TransactionsPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/adjustments" element={<AdjustmentsPage />} />
|
||||
<Route path="/budget" element={<BudgetPage />} />
|
||||
{/* 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/highlights" element={<ReportsHighlightsPage />} />
|
||||
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
|
||||
<Route path="/reports/compare" element={<ReportsComparePage />} />
|
||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||
<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 path="/balance" element={<BalancePage />} />
|
||||
<Route path="/balance/accounts" element={<AccountsPage />} />
|
||||
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
||||
<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 />}
|
||||
|
|
|
|||
|
|
@ -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<string, React.ComponentType<{ size?: number }>> = {
|
||||
|
|
@ -30,6 +33,31 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
|||
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 (
|
||||
<span
|
||||
className="ml-auto opacity-60"
|
||||
title={t("nav.locked")}
|
||||
aria-label={t("nav.locked")}
|
||||
role="img"
|
||||
>
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
|
@ -64,6 +92,7 @@ export default function Sidebar() {
|
|||
>
|
||||
{Icon && <Icon size={18} />}
|
||||
<span>{t(item.labelKey)}</span>
|
||||
{item.feature && <NavLock feature={item.feature} />}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Link
|
||||
to={to}
|
||||
className="group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
||||
className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
||||
>
|
||||
{locked && (
|
||||
<span
|
||||
className="absolute top-4 right-4 text-[var(--muted-foreground)]"
|
||||
title={t("nav.locked")}
|
||||
aria-label={t("nav.locked")}
|
||||
role="img"
|
||||
>
|
||||
<Lock size={16} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="text-[var(--primary)]">{icon}</div>
|
||||
<h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]">
|
||||
{title}
|
||||
|
|
|
|||
|
|
@ -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: <Sparkles size={24} />,
|
||||
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: <Scale size={24} />,
|
||||
title: t("reports.hub.compare"),
|
||||
description: t("reports.hub.compareDescription"),
|
||||
locked: advancedLocked,
|
||||
},
|
||||
{
|
||||
to: `/reports/category${preserveSearch}`,
|
||||
icon: <Search size={24} />,
|
||||
title: t("reports.hub.categoryZoom"),
|
||||
description: t("reports.hub.categoryZoomDescription"),
|
||||
locked: advancedLocked,
|
||||
},
|
||||
{
|
||||
to: `/reports/cartes${preserveSearch}`,
|
||||
icon: <LayoutDashboard size={24} />,
|
||||
title: t("reports.hub.cartes"),
|
||||
description: t("reports.hub.cartesDescription"),
|
||||
locked: advancedLocked,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
37
src/shared/constants/index.test.ts
Normal file
37
src/shared/constants/index.test.ts
Normal file
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
Loading…
Reference in a new issue