feat(gating): UI guard — RequireFeature + UpsellGate + i18n
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:
parent
b9e13b5bca
commit
554373e7d8
6 changed files with 238 additions and 3 deletions
43
src/components/shared/RequireFeature.tsx
Normal file
43
src/components/shared/RequireFeature.tsx
Normal 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 />;
|
||||
}
|
||||
70
src/components/shared/UpsellGate.tsx
Normal file
70
src/components/shared/UpsellGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue