diff --git a/src/components/shared/RequireFeature.tsx b/src/components/shared/RequireFeature.tsx
new file mode 100644
index 0000000..bf9fdb7
--- /dev/null
+++ b/src/components/shared/RequireFeature.tsx
@@ -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 ):
+ * }> …sub-routes
+ * - explicit wrapper:
+ *
+ */
+export default function RequireFeature({ feature, children }: RequireFeatureProps) {
+ const { allowed, ready } = useEntitlement(feature);
+
+ if (!ready) {
+ return (
+
+
+
+ );
+ }
+
+ if (!allowed) {
+ return ;
+ }
+
+ return children !== undefined ? <>{children}> : ;
+}
diff --git a/src/components/shared/UpsellGate.tsx b/src/components/shared/UpsellGate.tsx
new file mode 100644
index 0000000..715a0c6
--- /dev/null
+++ b/src/components/shared/UpsellGate.tsx
@@ -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 " — 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.` (FR/EN).
+ */
+export default function UpsellGate({ feature, requiredTier }: UpsellGateProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const tier = t(`license.editions.${requiredTier}`);
+
+ return (
+
+
+
+
+
+
+ {t("upsell.title", { tier })}
+
+
+ {t(`upsell.features.${feature}`)}
+
+
+
+
+
+
+
+ {t("upsell.ctaGetSoon")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index cc5cc61..693b68d 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -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",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index fb7aa62..593fad0 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -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",
diff --git a/src/services/entitlements.test.ts b/src/services/entitlements.test.ts
index 23f964f..d1bf871 100644
--- a/src/services/entitlements.test.ts
+++ b/src/services/entitlements.test.ts
@@ -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;
+ ctaGet: string;
+ ctaGetSoon: string;
+ ctaHaveKey: string;
+ };
+ }
+ const locales: Record = { 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. label keys required by the CTA", () => {
+ Object.values(locales).forEach((messages) => {
+ expect(messages.license.editions.base).toBeTruthy();
+ expect(messages.license.editions.premium).toBeTruthy();
+ });
+ });
+});
diff --git a/src/shared/entitlements.ts b/src/shared/entitlements.ts
index 0d879fa..ead581a 100644
--- a/src/shared/entitlements.ts
+++ b/src/shared/entitlements.ts
@@ -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";
+}