feat(gating): UI guard — RequireFeature + UpsellGate + i18n
All checks were successful
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m28s

Add the reusable gating guard components on top of the #297 foundation:

- UpsellGate: full locked screen (lock icon, tier title, per-feature
  description). Two CTAs: "Get <tier>" rendered VISIBLE but DISABLED with
  an "online purchase coming soon" note (per planning decision — #270 will
  activate it), and "I already have a key" navigating to /settings/users
  (LicenseCard).
- RequireFeature: renders a neutral loader while the license is not ready
  (no upsell flash at boot), then children or UpsellGate. Renders <Outlet/>
  when children are omitted so it also works as a layout route grouping
  all routes of one feature.
- requiredTierFor() pure helper in shared/entitlements.ts derives the
  minimum unlocking tier from matrix membership (not array order).
- i18n: upsell.* (title, per-feature descriptions, CTAs) + nav.locked in
  BOTH locales; tier labels reuse the existing license.editions.* keys.
- Tests: requiredTierFor mapping/minimality + upsell i18n coverage for
  every FeatureKey in fr and en (the components themselves are not
  testable without jsdom).

Resolves #298

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-20 22:05:04 -04:00
parent b9e13b5bca
commit 554373e7d8
6 changed files with 238 additions and 3 deletions

View file

@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { Outlet } from "react-router-dom";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor, type FeatureKey } from "../../shared/entitlements";
import UpsellGate from "./UpsellGate";
interface RequireFeatureProps {
feature: FeatureKey;
children?: ReactNode;
}
/**
* Route guard for gated features (UI-only soft paywall).
*
* While the license is still loading or errored (`!ready`) it renders a
* NEUTRAL loader never the upsell so a paying user never sees a
* "locked" flash at boot or during a transient IPC failure (the retry loop
* lives in LicenseProvider; this component only respects `ready`).
*
* Usable two ways:
* - layout route grouping all routes of one feature (children omitted
* renders <Outlet/>):
* <Route element={<RequireFeature feature="balance" />}> sub-routes
* - explicit wrapper:
* <RequireFeature feature="budget"><BudgetPage /></RequireFeature>
*/
export default function RequireFeature({ feature, children }: RequireFeatureProps) {
const { allowed, ready } = useEntitlement(feature);
if (!ready) {
return (
<div className="flex min-h-full items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
</div>
);
}
if (!allowed) {
return <UpsellGate feature={feature} requiredTier={requiredTierFor(feature)} />;
}
return children !== undefined ? <>{children}</> : <Outlet />;
}

View file

@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { KeyRound, Lock, ShoppingCart } from "lucide-react";
import type { Edition } from "../../services/licenseService";
import type { FeatureKey } from "../../shared/entitlements";
interface UpsellGateProps {
feature: FeatureKey;
requiredTier: Edition;
}
/**
* Full locked screen shown in place of a gated module (soft paywall).
*
* Two CTAs:
* - "Get <tier>" VISIBLE but DISABLED with a "coming soon" note: the online
* purchase flow (#270 / Stripe) is not live yet. It will be wired by #270.
* - "I already have a key" the active flow, navigates to the license card
* at /settings/users.
*
* The tier label reuses the existing `license.editions.*` keys; the feature
* description comes from `upsell.features.<FeatureKey>` (FR/EN).
*/
export default function UpsellGate({ feature, requiredTier }: UpsellGateProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const tier = t(`license.editions.${requiredTier}`);
return (
<div className="flex min-h-full items-center justify-center p-4">
<div className="max-w-md w-full space-y-6 text-center">
<Lock className="mx-auto h-16 w-16 text-[var(--muted-foreground)]" />
<div className="space-y-2">
<h1 className="text-2xl font-bold text-[var(--foreground)]">
{t("upsell.title", { tier })}
</h1>
<p className="text-[var(--muted-foreground)]">
{t(`upsell.features.${feature}`)}
</p>
</div>
<div className="flex flex-col gap-3">
<div>
<button
type="button"
disabled
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] opacity-50 cursor-not-allowed"
>
<ShoppingCart className="h-4 w-4" />
{t("upsell.ctaGet", { tier })}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("upsell.ctaGetSoon")}
</p>
</div>
<button
type="button"
onClick={() => navigate("/settings/users")}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<KeyRound className="h-4 w-4" />
{t("upsell.ctaHaveKey")}
</button>
</div>
</div>
</div>
);
}

View file

@ -16,7 +16,8 @@
"budget": "Budget",
"reports": "Reports",
"balance": "Balance sheet",
"settings": "Settings"
"settings": "Settings",
"locked": "Locked feature"
},
"dashboard": {
"title": "Dashboard",
@ -1124,6 +1125,19 @@
"noMachines": "No machines activated"
}
},
"upsell": {
"title": "{{tier}} feature",
"features": {
"budget": "Plan monthly budgets by category and compare them against your actual spending.",
"adjustments": "Add manual entries and split transactions across multiple categories.",
"reports-advanced": "Explore the advanced reports: Highlights, Compare, Category Analysis and Cards.",
"multi-profile": "Create multiple profiles, each with its own local database and PIN protection.",
"balance": "Track your net worth: accounts, snapshots, per-security detail and market prices."
},
"ctaGet": "Get {{tier}}",
"ctaGetSoon": "Online purchase coming soon",
"ctaHaveKey": "I already have a key"
},
"account": {
"title": "Maximus Account",
"optional": "Optional",

View file

@ -16,7 +16,8 @@
"budget": "Budget",
"reports": "Rapports",
"balance": "Bilan",
"settings": "Paramètres"
"settings": "Paramètres",
"locked": "Fonctionnalité verrouillée"
},
"dashboard": {
"title": "Tableau de bord",
@ -1124,6 +1125,19 @@
"noMachines": "Aucune machine activée"
}
},
"upsell": {
"title": "Fonctionnalité {{tier}}",
"features": {
"budget": "Planifiez des budgets mensuels par catégorie et comparez-les à vos dépenses réelles.",
"adjustments": "Ajoutez des écritures manuelles et fractionnez des transactions sur plusieurs catégories.",
"reports-advanced": "Explorez les rapports avancés : Faits saillants, Comparables, Analyse par catégorie et Cartes.",
"multi-profile": "Créez plusieurs profils, chacun avec sa base de données locale et sa protection par NIP.",
"balance": "Suivez votre patrimoine : comptes, instantanés, détail par titre et cours du marché."
},
"ctaGet": "Obtenir {{tier}}",
"ctaGetSoon": "Achat en ligne bientôt disponible",
"ctaHaveKey": "J'ai déjà une clé"
},
"account": {
"title": "Compte Maximus",
"optional": "Optionnel",

View file

@ -1,8 +1,15 @@
import { describe, it, expect } from "vitest";
// Subject lives in src/shared/entitlements.ts; test co-located here per the
// issue's file plan (services/).
import { isEntitled, ENTITLEMENTS, type FeatureKey } from "../shared/entitlements";
import {
isEntitled,
requiredTierFor,
ENTITLEMENTS,
type FeatureKey,
} from "../shared/entitlements";
import type { Edition } from "./licenseService";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
const EDITIONS: Edition[] = ["free", "base", "premium"];
const FEATURES = Object.keys(ENTITLEMENTS) as FeatureKey[];
@ -70,3 +77,81 @@ describe("isEntitled — unknown feature", () => {
expect(isEntitled("web-sync" as FeatureKey, "free", ["web-sync"])).toBe(false);
});
});
describe("requiredTierFor — upsell tier derivation", () => {
it("maps every Base-tier feature to base, and balance to premium", () => {
expect(requiredTierFor("budget")).toBe("base");
expect(requiredTierFor("adjustments")).toBe("base");
expect(requiredTierFor("reports-advanced")).toBe("base");
expect(requiredTierFor("multi-profile")).toBe("base");
expect(requiredTierFor("balance")).toBe("premium");
});
it("returns the MINIMUM tier satisfying isEntitled, never free", () => {
FEATURES.forEach((f) => {
const tier = requiredTierFor(f);
expect(tier).not.toBe("free");
// The returned tier does unlock the feature…
expect(isEntitled(f, tier, [])).toBe(true);
// …and is minimal: premium only when base does not unlock it.
if (tier === "premium") {
expect(isEntitled(f, "base", [])).toBe(false);
}
});
});
});
describe("upsell i18n coverage (fr + en)", () => {
// UpsellGate renders t(`upsell.features.${feature}`) — every FeatureKey must
// resolve in BOTH locales or a locked screen shows a raw key to the user.
// Structural sub-type only: full typeof-identity between the two JSON files
// is not this test's concern.
interface UpsellMessages {
nav: { locked: string };
license: { editions: { base: string; premium: string } };
upsell: {
title: string;
features: Record<string, string>;
ctaGet: string;
ctaGetSoon: string;
ctaHaveKey: string;
};
}
const locales: Record<string, UpsellMessages> = { fr, en };
it("has a non-empty upsell description for every FeatureKey in both locales", () => {
Object.entries(locales).forEach(([lng, messages]) => {
FEATURES.forEach((f) => {
expect(
messages.upsell.features[f],
`missing upsell.features.${f} in ${lng}`,
).toBeTruthy();
});
});
});
it("has no stale upsell.features key outside the ENTITLEMENTS matrix", () => {
Object.entries(locales).forEach(([lng, messages]) => {
Object.keys(messages.upsell.features).forEach((k) => {
expect(FEATURES, `stale upsell.features.${k} in ${lng}`).toContain(k);
});
});
});
it("exposes the title, CTA keys and nav.locked in both locales", () => {
Object.values(locales).forEach((messages) => {
expect(messages.upsell.title).toBeTruthy();
expect(messages.upsell.ctaGet).toBeTruthy();
expect(messages.upsell.ctaGetSoon).toBeTruthy();
expect(messages.upsell.ctaHaveKey).toBeTruthy();
expect(messages.nav.locked).toBeTruthy();
});
});
it("keeps the license.editions.<tier> label keys required by the CTA", () => {
Object.values(locales).forEach((messages) => {
expect(messages.license.editions.base).toBeTruthy();
expect(messages.license.editions.premium).toBeTruthy();
});
});
});

View file

@ -48,3 +48,12 @@ export function isEntitled(
if (!tiers) return licenseFeatures.includes(f);
return tiers.includes(edition) || licenseFeatures.includes(f);
}
/**
* Minimum paid tier that unlocks a feature drives the upsell copy
* ("Obtenir Base" vs "Obtenir Premium"). Derived from matrix MEMBERSHIP, not
* array order, so reordering an ENTITLEMENTS row can never change the answer.
*/
export function requiredTierFor(f: FeatureKey): Edition {
return ENTITLEMENTS[f].includes("base") ? "base" : "premium";
}