From 554373e7d8fa1227673f1d764f09227fddee9a9a Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 20 Jul 2026 22:05:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(gating):=20UI=20guard=20=E2=80=94=20Requir?= =?UTF-8?q?eFeature=20+=20UpsellGate=20+=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 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 --- src/components/shared/RequireFeature.tsx | 43 ++++++++++++ src/components/shared/UpsellGate.tsx | 70 +++++++++++++++++++ src/i18n/locales/en.json | 16 ++++- src/i18n/locales/fr.json | 16 ++++- src/services/entitlements.test.ts | 87 +++++++++++++++++++++++- src/shared/entitlements.ts | 9 +++ 6 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 src/components/shared/RequireFeature.tsx create mode 100644 src/components/shared/UpsellGate.tsx 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"; +}