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>
49 lines
1.4 KiB
TypeScript
49 lines
1.4 KiB
TypeScript
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,
|
|
locked,
|
|
}: HubReportNavCardProps) {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<Link
|
|
to={to}
|
|
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}
|
|
</h3>
|
|
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
|
</Link>
|
|
);
|
|
}
|