feat(gating): routes + Sidebar (budget, rapports avancés, bilan) #305

Closed
maximus wants to merge 1 commit from issue-299-routes-sidebar into issue-298-require-feature
7 changed files with 135 additions and 11 deletions

View file

@ -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 />} />
{/* 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 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 />}

View file

@ -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>
);
})}

View file

@ -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}

View file

@ -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,
},
];

View 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",
]);
});
});

View file

@ -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",

View file

@ -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 ---