feat(upsell): activate "Get Base", honest Premium note, purchase path in Free, Account card text #362

Merged
maximus merged 2 commits from issue-346-purchase-cta into main 2026-09-28 00:51:07 +00:00
19 changed files with 545 additions and 100 deletions

View file

@ -2,6 +2,17 @@
## [Non publié]
### Ajouté
- Achat de l'édition Base depuis chaque écran qui la requiert : « Obtenir Base » est désormais actif sur Budget, Ajustements, les rapports avancés et les profils supplémentaires, et ouvre la page produit dans la langue de l'application (#346).
- En édition Gratuite, la carte Mises à jour propose « Obtenir Base », « J'ai déjà une clé » et « Télécharger la dernière version », et la page d'erreur « Obtenir Base » et « Télécharger la dernière version ». Le lien de téléchargement ouvre la page produit, qui sert les derniers installateurs ; aucune vérification de mise à jour n'a lieu (#346).
### Modifié
- La carte Compte Maximus n'affirme plus qu'un compte est requis pour Premium : le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions, et les éditions viennent de la clé de licence (#346).
- L'écran verrouillé du Bilan (Premium) indique désormais que Premium n'est pas encore offert en ligne, au lieu d'« Achat en ligne bientôt disponible » (#346).
- En édition Gratuite, la carte Mises à jour indique que les mises à jour automatiques sont incluses dans l'édition Base (#346).
### Corrigé
- L'activation, la désactivation et la liste des machines passent désormais par les points d'accès `/v1` du serveur de licences et nomment le produit, au lieu de passer par une redirection à chaque appel (#270).

View file

@ -2,6 +2,17 @@
## [Unreleased]
### Added
- Buy the Base edition from every screen that requires it: "Get Base" is now active on Budget, Adjustments, the advanced reports and extra profiles, and opens the product page in the app's language (#346).
- In the free edition, the Updates card offers "Get Base", "I already have a key" and "Download the latest version", and the error page offers "Get Base" and "Download the latest version". The download link opens the product page, which serves the latest installers; no update check runs (#346).
### Changed
- The Maximus Account card no longer says an account is required for Premium: the account lets you find your licenses on lacompagniemaximus.com and identifies you in your feedback, and editions come from the license key (#346).
- The Balance locked screen (Premium) now says Premium isn't sold online yet, instead of "Online purchase coming soon" (#346).
- In the free edition, the Updates card says automatic updates are included in the Base edition (#346).
### Fixed
- Machine activation, deactivation and the machine list now call the license server's current `/v1` endpoints and name the product explicitly, instead of going through a redirect on every call (#270).

View file

@ -6,6 +6,8 @@
- Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md)
- S'appuie sur la licence JWT Ed25519 machine-bindée (Phase 3b monétisation) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur)
> **Amendement du 2026-09-27 (#346)** — le CTA d'achat n'est plus inerte. « Obtenir Base » est actif sur tous les écrans verrouillés Base : il ouvre la page produit dans la langue de l'app (`purchaseUrl`, livré par #270, via le composant `PurchaseButton`). Premium reste désactivé, avec la note « Premium n'est pas encore offert en ligne. », tant qu'aucune offre Premium n'est vendable ; la règle vit dans une fonction pure testée, `upsellCtaFor` (`src/shared/upsellCta.ts`). En édition Gratuite, la carte Mises à jour et la page d'erreur proposent aussi « Obtenir Base » et « Télécharger la dernière version » (page produit), sans vérification de mise à jour ni appel réseau. `ProfileFormModal` réutilise désormais `UpsellGate` : le « mode upsell compact » a disparu, et « J'ai déjà une clé » ne s'affiche que sous un routeur (`HaveKeyButton`). Réserve sur la carte Compte Maximus : son texte dit que les éditions viennent de la clé de licence, pas du compte, ce qui n'est exact qu'en pratique. `current_entitlements` accorde encore Premium à un compte dont le cache signé porte un abonnement actif (`check_account_edition`, `license_commands.rs`), comme le rappelle le Contexte ci-dessous. Ce chemin reste dormant tant qu'aucun abonnement n'est vendu ; le jour où un abonnement existera, il faudra revoir ce texte ou retirer le chemin. Le reste de la décision est inchangé.
## Contexte
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 liée à la machine (ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
@ -63,11 +65,11 @@ Le gating est appliqué **uniquement dans l'interface** (routes, navigation, poi
### 4. Upsell verrouillé visible, jamais masqué
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**.
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**.
### 5. Durcissement sec, non destructif
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, machine changée) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, machine changée) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
### 6. Dev-override compilé hors des builds normaux
@ -108,7 +110,7 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
### Négatives / risques actés
- **Contournable par build local** : assumé (GPL, soft-paywall). Ne jamais présenter ce gating comme une protection — la seule barrière réelle est serveur (cours).
- **CTA d'achat inerte** : « Obtenir \<tier\> » est affiché désactivé (« bientôt ») tant que #270 (activation en ligne + URL d'achat) n'est pas livré. Fenêtre où l'upsell promet sans vendre — la voie « J'ai déjà une clé » reste fonctionnelle.
- **CTA d'achat inerte** : « Obtenir \<tier\> » est affiché désactivé (« bientôt ») tant que #270 (activation en ligne + URL d'achat) n'est pas livré. Fenêtre où l'upsell promet sans vendre — la voie « J'ai déjà une clé » reste fonctionnelle (voir l'amendement en tête).
- **Deux tables à ne pas confondre** : un futur gate ajouté côté Rust dans `FEATURE_TIERS` ne gaterait rien dans l'UI, et réciproquement. La règle est documentaire (cet ADR + commentaires des deux modules).
- **`dev-override` à surveiller en release** : la feature Cargo ne doit jamais apparaître dans un build publié ; le choix opt-in la rend improbable, pas impossible.
@ -126,4 +128,4 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (machine-binding), `dev_override_edition` (CWE-489)
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (câblage du CTA d'achat, à venir)
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête)

View file

@ -37,13 +37,13 @@ simpl-resultat/
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
│ │ ├── shared/ # 9 composants réutilisables (dont RequireFeature, UpsellGate)
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton)
│ │ └── transactions/ # 5 composants
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 14 services métier
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts)
│ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts)
│ ├── i18n/ # Config i18next + locales FR/EN
│ ├── App.tsx # Router principal
@ -296,9 +296,11 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status` |
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }` — `allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé : CTA « Obtenir \<tier\> » **désactivé** avec mention « bientôt » (câblage boutique par #270) + « J'ai déjà une clé » → `/settings/users` (prop `onNavigate?` pour les hôtes modaux) |
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé, CTA décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas |
| CTA partagés | `src/components/shared/PurchaseButton.tsx`, `DownloadLatestButton.tsx`, `HaveKeyButton.tsx` | « Obtenir Base » et « Télécharger la dernière version » ouvrent `purchaseUrl` dans le navigateur, sans routeur ; ils servent aussi la carte Mises à jour et `ErrorPage` en édition Gratuite, sans aucune vérification de mise à jour. « J'ai déjà une clé » ne rend rien hors `BrowserRouter` (#346) |
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell compact) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell, qui réutilise `UpsellGate`) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
| Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` |
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
@ -347,7 +349,7 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. #347 étend l'enum aux erreurs de saisie de clé.
Le lien d'achat de la carte Licence, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)

View file

@ -542,18 +542,20 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan) et sur les tuiles de rapports avancés du hub Rapports. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
- **Obtenir Base / Premium** — l'achat en ligne arrive bientôt ; le bouton est affiché mais désactivé en attendant
- **Obtenir Base** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton s'intitule « Obtenir Premium » et reste désactivé : Premium n'est pas encore offert en ligne
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
### Comment faire
1. Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition
2. Cliquez sur un module verrouillé pour voir l'édition requise
3. Si vous avez une clé de licence, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
4. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire
3. Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
5. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire
### Astuces
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, changement de machine), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
- En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite

View file

@ -1,10 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Trash2, Lock, LockOpen, Plus, ShoppingCart } from "lucide-react";
import { X, Trash2, Lock, LockOpen, Plus } from "lucide-react";
import { useProfile } from "../../contexts/ProfileContext";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor } from "../../shared/entitlements";
import { isProfileCreationLocked } from "../../shared/profileGate";
import UpsellGate from "../shared/UpsellGate";
const PRESET_COLORS = [
"#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6",
@ -26,7 +27,6 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
// non-destructive and only blocks creating a profile beyond the first.
const gate = useEntitlement("multi-profile");
const creationLocked = isProfileCreationLocked(profiles.length, gate);
const upsellTier = t(`license.editions.${requiredTierFor("multi-profile")}`);
const editProfile = editProfileId
? profiles.find((p) => p.id === editProfileId)
@ -176,35 +176,18 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
</button>
</div>
) : mode === "upsell" ? (
/* Compact upsell panel — NOT the shared <UpsellGate/>: this modal
also opens from ProfileSelectionPage, which renders OUTSIDE
BrowserRouter (App.tsx mounts the router only once a profile is
active), so UpsellGate's unconditional useNavigate would throw.
Same i18n keys; the "I already have a key" CTA is omitted here —
the ProfileSwitcher upsell dialog (always in-router) exposes it. */
<div className="space-y-4 py-2 text-center">
<Lock className="mx-auto h-12 w-12 text-[var(--muted-foreground)]" />
<div className="space-y-1">
<h3 className="font-semibold text-[var(--foreground)]">
{t("upsell.title", { tier: upsellTier })}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("upsell.features.multi-profile")}
</p>
</div>
<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: upsellTier })}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("upsell.ctaGetSoon")}
</p>
</div>
/* Shared <UpsellGate/> (#346). This modal also opens from
ProfileSelectionPage, rendered OUTSIDE BrowserRouter (App.tsx mounts
the router only once a profile is active): the gate's "I already
have a key" button renders only under a router, so it shows when the
modal comes from ProfileSwitcher and closes the modal after the
navigation (onNavigate). Cancel goes back to the profile list. */
<div className="space-y-2">
<UpsellGate
feature="multi-profile"
requiredTier={requiredTierFor("multi-profile")}
onNavigate={onClose}
/>
<button
onClick={() => setMode("list")}
className="w-full px-4 py-2 rounded-lg border border-[var(--border)] text-sm text-[var(--foreground)] hover:bg-[var(--muted)]"

View file

@ -6,6 +6,7 @@ import { useLicenseContext } from "../../contexts/LicenseContext";
import { useLicenseMachines } from "../../hooks/useLicenseMachines";
import type { LicenseError } from "../../services/licenseService";
import { purchaseUrl } from "../../shared/purchaseUrl";
import { logError } from "../../services/logService";
export default function LicenseCard() {
const { t, i18n } = useTranslation();
@ -37,7 +38,9 @@ export default function LicenseCard() {
};
const handlePurchase = () => {
void openUrl(purchaseUrl(i18n.language));
void openUrl(purchaseUrl(i18n.language)).catch((e) =>
logError("Product page: openUrl failed", e),
);
};
const formatExpiry = (timestamp: number) => {

View file

@ -10,6 +10,9 @@ import {
Loader2,
} from "lucide-react";
import { useUpdater } from "../../hooks/useUpdater";
import PurchaseButton from "../shared/PurchaseButton";
import HaveKeyButton from "../shared/HaveKeyButton";
import DownloadLatestButton from "../shared/DownloadLatestButton";
export default function UpdateCard() {
const { t, i18n } = useTranslation();
@ -72,10 +75,20 @@ export default function UpdateCard() {
</div>
)}
{/* Free edition: automatic updates are locked and no update check runs
(no network call). The card offers to buy Base, to enter a key, or to
download the latest version from the product page (#346). */}
{state.status === "notEntitled" && (
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.updates.notEntitled")}</p>
<div className="space-y-3">
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.updates.notEntitled")}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<PurchaseButton className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm" />
<HaveKeyButton className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm" />
</div>
<DownloadLatestButton />
</div>
)}

View file

@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { Download } from "lucide-react";
import { purchaseUrl } from "../../shared/purchaseUrl";
import { logError } from "../../services/logService";
const DEFAULT_LAYOUT = "inline-flex items-center justify-center gap-2 text-sm";
const COLOURS = "text-[var(--primary)] hover:underline";
interface DownloadLatestButtonProps {
/** Replaces the default layout classes; colours stay fixed. */
className?: string;
}
/**
* "Download the latest version", shown in the free edition where automatic updates
* are locked (issue #346). It opens the product page, which serves the latest
* installers directly (la-compagnie-maximus#153). No update check runs here: the
* free edition makes no network call for updates. A failure to open the browser
* is logged, like PurchaseButton's.
*/
export default function DownloadLatestButton({
className = DEFAULT_LAYOUT,
}: DownloadLatestButtonProps) {
const { t, i18n } = useTranslation();
return (
<button
type="button"
onClick={() =>
void openUrl(purchaseUrl(i18n.language)).catch((e) =>
logError("Product page: openUrl failed", e),
)
}
className={`${className} ${COLOURS}`}
>
<Download className="h-4 w-4" />
{t("settings.updates.downloadLatest")}
</button>
);
}

View file

@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
import { check } from "@tauri-apps/plugin-updater";
import { invoke } from "@tauri-apps/api/core";
import PurchaseButton from "./PurchaseButton";
import DownloadLatestButton from "./DownloadLatestButton";
interface ErrorPageProps {
error?: string;
@ -97,10 +99,15 @@ export default function ErrorPage({ error }: ErrorPageProps) {
{t("error.upToDate")}
</p>
)}
{/* Outside the router: no "I already have a key" here (#346). */}
{updateStatus === "notEntitled" && (
<p className="text-sm text-[var(--muted-foreground)]">
{t("error.updateNotEntitled")}
</p>
<div className="space-y-3">
<p className="text-sm text-[var(--muted-foreground)]">
{t("error.updateNotEntitled")}
</p>
<PurchaseButton />
<DownloadLatestButton />
</div>
)}
{updateStatus === "error" && updateError && (
<p className="text-sm text-[var(--destructive)]">{updateError}</p>

View file

@ -0,0 +1,46 @@
import { useTranslation } from "react-i18next";
import { useInRouterContext, useNavigate } from "react-router-dom";
import { KeyRound } from "lucide-react";
const DEFAULT_LAYOUT = "inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md";
const COLOURS =
"border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors";
interface HaveKeyButtonProps {
/**
* Called right after the navigation to the license card. Lets modal hosts close
* themselves: the Sidebar stays mounted across navigations, so an uncontrolled
* modal would linger on top of the destination page.
*/
onNavigate?: () => void;
/** Replaces the default layout classes; colours stay fixed. */
className?: string;
}
/**
* "I already have a key": navigates to the license card (/settings/users). Renders
* nothing outside BrowserRouter (ProfileSelectionPage, ErrorPage), where
* useNavigate would throw — useInRouterContext is safe anywhere, hence the split
* into an inner component.
*/
export default function HaveKeyButton(props: HaveKeyButtonProps) {
return useInRouterContext() ? <HaveKeyButtonInRouter {...props} /> : null;
}
function HaveKeyButtonInRouter({ onNavigate, className = DEFAULT_LAYOUT }: HaveKeyButtonProps) {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<button
type="button"
onClick={() => {
navigate("/settings/users");
onNavigate?.();
}}
className={`${className} ${COLOURS}`}
>
<KeyRound className="h-4 w-4" />
{t("upsell.ctaHaveKey")}
</button>
);
}

View file

@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { ShoppingCart } from "lucide-react";
import { purchaseUrl } from "../../shared/purchaseUrl";
import { logError } from "../../services/logService";
const DEFAULT_LAYOUT = "w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md";
const COLOURS =
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity";
interface PurchaseButtonProps {
/** Replaces the default layout classes (width, padding, radius); colours stay fixed. */
className?: string;
}
/**
* "Get Base": opens the localized product page in the system browser (issue #346).
* Needs no router, so it also works on ErrorPage and in modals rendered outside
* BrowserRouter. It never calls a modal host's onNavigate: the page opens in the
* browser and the app stays where it was. A failure to open the browser is
* logged, so it shows up in the in-app logs instead of vanishing.
*/
export default function PurchaseButton({ className = DEFAULT_LAYOUT }: PurchaseButtonProps) {
const { t, i18n } = useTranslation();
return (
<button
type="button"
onClick={() =>
void openUrl(purchaseUrl(i18n.language)).catch((e) =>
logError("Product page: openUrl failed", e),
)
}
className={`${className} ${COLOURS}`}
>
<ShoppingCart className="h-4 w-4" />
{t("upsell.ctaGet", { tier: t("license.editions.base") })}
</button>
);
}

View file

@ -0,0 +1,132 @@
// Render tests for the shared upsell CTAs (#346). No jsdom needed:
// react-dom/server renders in vitest's node environment, and calling a
// component as a function (FilterPanel.test.tsx pattern) exposes its props.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
const openUrl = vi.fn((_url: string) => Promise.resolve());
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: (u: string) => openUrl(u) }));
const logError = vi.fn();
vi.mock("../../services/logService", async (importOriginal) => ({
...(await importOriginal<typeof import("../../services/logService")>()),
logError: (...args: unknown[]) => logError(...args),
}));
let language = "fr";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
i18n: { language },
}),
}));
vi.mock("../../hooks/useUpdater", () => ({
useUpdater: () => ({
state: { status: "notEntitled", version: null, body: null, progress: 0, contentLength: null, error: null },
checkForUpdate: vi.fn(),
downloadAndInstall: vi.fn(),
installAndRestart: vi.fn(),
}),
}));
import UpsellGate from "./UpsellGate";
import PurchaseButton from "./PurchaseButton";
import DownloadLatestButton from "./DownloadLatestButton";
import HaveKeyButton from "./HaveKeyButton";
import UpdateCard from "../settings/UpdateCard";
function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as ReactElement<{ children?: ReactNode }>;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
beforeEach(() => {
openUrl.mockClear();
logError.mockClear();
language = "fr";
});
describe("UpsellGate wiring", () => {
it("Base screen: active Get Base, no note, no disabled button", () => {
const html = renderToStaticMarkup(
<MemoryRouter>
<UpsellGate feature="budget" requiredTier="base" />
</MemoryRouter>,
);
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
expect(html).not.toContain("disabled");
expect(html).not.toContain("upsell.premiumNotForSale");
expect(html).toContain("upsell.ctaHaveKey");
});
it("Premium screen: disabled button with the not-for-sale note", () => {
const html = renderToStaticMarkup(
<MemoryRouter>
<UpsellGate feature="balance" requiredTier="premium" />
</MemoryRouter>,
);
expect(html).toContain('disabled=""');
expect(html).toContain("upsell.ctaGet[tier=license.editions.premium]");
expect(html).toContain("upsell.premiumNotForSale");
});
it("outside any router (ProfileSelectionPage, ErrorPage): renders, without I already have a key", () => {
const html = renderToStaticMarkup(<UpsellGate feature="multi-profile" requiredTier="base" />);
expect(html).toContain("upsell.ctaGet");
expect(html).not.toContain("upsell.ctaHaveKey");
});
it("forwards onNavigate to I already have a key only", () => {
const onNavigate = vi.fn();
const tree = UpsellGate({ feature: "budget", requiredTier: "base", onNavigate });
const haveKey = find(tree, (el) => el.type === HaveKeyButton);
expect(haveKey).toHaveLength(1);
expect((haveKey[0].props as { onNavigate?: () => void }).onNavigate).toBe(onNavigate);
const purchase = find(tree, (el) => el.type === PurchaseButton);
expect(purchase).toHaveLength(1);
expect(purchase[0].props).not.toHaveProperty("onNavigate");
});
});
// Both buttons open the product page. A failure to open the browser must reach
// the in-app logs, not vanish as an unhandled rejection.
function productPageButtonTests(Button: (props: { className?: string }) => ReactNode) {
it("opens the product page in the app language", () => {
language = "en";
const button = Button({}) as ReactElement<{ onClick: () => void }>;
button.props.onClick();
expect(openUrl).toHaveBeenCalledWith("https://lacompagniemaximus.com/en/produits/simpl-resultat");
});
it("logs a failure to open the browser", async () => {
const failure = new Error("no browser");
openUrl.mockRejectedValueOnce(failure);
const button = Button({}) as ReactElement<{ onClick: () => void }>;
button.props.onClick();
await vi.waitFor(() => expect(logError).toHaveBeenCalledWith(expect.any(String), failure));
});
}
describe("PurchaseButton", () => productPageButtonTests(PurchaseButton));
describe("DownloadLatestButton", () => productPageButtonTests(DownloadLatestButton));
describe("UpdateCard in the free edition", () => {
it("offers Get Base, I already have a key and Download the latest version", () => {
const html = renderToStaticMarkup(
<MemoryRouter>
<UpdateCard />
</MemoryRouter>,
);
expect(html).toContain("settings.updates.notEntitled");
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
expect(html).toContain("upsell.ctaHaveKey");
expect(html).toContain("settings.updates.downloadLatest");
});
});

View file

@ -1,17 +1,21 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { KeyRound, Lock, ShoppingCart } from "lucide-react";
import { Lock, ShoppingCart } from "lucide-react";
import type { Edition } from "../../services/licenseService";
import type { FeatureKey } from "../../shared/entitlements";
import { upsellCtaFor } from "../../shared/upsellCta";
import PurchaseButton from "./PurchaseButton";
import HaveKeyButton from "./HaveKeyButton";
interface UpsellGateProps {
feature: FeatureKey;
requiredTier: Edition;
/**
* Called right after a CTA navigates away. Lets modal hosts (e.g. the
* ProfileSwitcher upsell dialog) close themselves — the Sidebar stays
* mounted across navigations, so an uncontrolled modal would linger on top
* of the destination page. Optional: route-level usage ignores it.
* Called right after "I already have a key" navigates to the license card. Lets
* modal hosts (the ProfileSwitcher upsell dialog, ProfileFormModal) close
* themselves — the Sidebar stays mounted across navigations, so an uncontrolled
* modal would linger on top of the destination page. The purchase button never
* calls it: the product page opens in the browser and the host stays open.
* Optional: route-level usage ignores it.
*/
onNavigate?: () => void;
}
@ -19,19 +23,21 @@ interface UpsellGateProps {
/**
* 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.
* Two CTAs, the first one decided by `upsellCtaFor` (issue #346):
* - "Get <tier>" — active for Base, which opens the product page in the browser
* (PurchaseButton); disabled for Premium, with a note, since no Premium offer
* is sold yet.
* - "I already have a key" — navigates to the license card at /settings/users.
* Rendered only inside BrowserRouter (HaveKeyButton), so the gate also works in
* ProfileFormModal opened from ProfileSelectionPage, outside the router.
*
* 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, onNavigate }: UpsellGateProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const tier = t(`license.editions.${requiredTier}`);
const cta = upsellCtaFor(requiredTier);
return (
<div className="flex min-h-full items-center justify-center p-4">
@ -48,31 +54,25 @@ export default function UpsellGate({ feature, requiredTier, onNavigate }: Upsell
</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>
{cta.purchasable ? (
<PurchaseButton />
) : (
<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>
{cta.noteKey && (
<p className="mt-1 text-xs text-[var(--muted-foreground)]">{t(cta.noteKey)}</p>
)}
</div>
)}
<button
type="button"
onClick={() => {
navigate("/settings/users");
onNavigate?.();
}}
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>
<HaveKeyButton onNavigate={onNavigate} />
</div>
</div>
</div>

View file

@ -583,7 +583,8 @@
"error": "Update failed",
"retryButton": "Retry",
"releaseNotes": "What's New",
"notEntitled": "Automatic updates are available with the Base edition. Activate a license to enable them."
"notEntitled": "Automatic updates are included in the Base edition.",
"downloadLatest": "Download the latest version"
},
"dataManagement": {
"title": "Data Management",
@ -1112,13 +1113,15 @@
"steps": [
"Look for the lock badge in the sidebar or on the Reports hub tiles: it marks the modules above your edition",
"Click a locked module to see which edition it requires",
"If you have a license key, click \"I already have a key\" (or go to Settings → Users) and enter it",
"Modules unlock immediately — no reinstall or restart needed; online purchase is coming soon, the \"Get\" button will be enabled once the store is live"
"To buy the Base edition, click \"Get Base\": the product page opens in your browser (Premium isn't sold online yet)",
"Once you have a license key, click \"I already have a key\" (or go to Settings → Users) and enter it",
"Modules unlock immediately — no reinstall or restart needed"
],
"tips": [
"Locking is never destructive: if your edition goes down (expired key, machine change), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
"On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked",
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer"
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer",
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free"
]
}
},
@ -1231,13 +1234,13 @@
"balance": "Track your net worth: accounts, snapshots, per-security detail and market prices."
},
"ctaGet": "Get {{tier}}",
"ctaGetSoon": "Online purchase coming soon",
"premiumNotForSale": "Premium isn't sold online yet.",
"ctaHaveKey": "I already have a key"
},
"account": {
"title": "Maximus Account",
"optional": "Optional",
"description": "Sign in to access Premium features (web version, sync). The account is only required for Premium features.",
"description": "Your account lets you find your licenses on lacompagniemaximus.com and identifies you in the feedback you send. Base and Premium editions come from your license key, not from the account.",
"signIn": "Sign in",
"signOut": "Sign out",
"connected": "Connected",

View file

@ -583,7 +583,8 @@
"error": "Erreur lors de la mise à jour",
"retryButton": "Réessayer",
"releaseNotes": "Nouveautés",
"notEntitled": "Les mises à jour automatiques sont disponibles avec l'édition Base. Activez une licence pour les utiliser."
"notEntitled": "Les mises à jour automatiques sont incluses dans l'édition Base.",
"downloadLatest": "Télécharger la dernière version"
},
"dataManagement": {
"title": "Gestion des données",
@ -1112,13 +1113,15 @@
"steps": [
"Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition",
"Cliquez sur un module verrouillé pour voir l'édition requise",
"Si vous avez une clé de licence, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la",
"Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire ; l'achat en ligne arrive bientôt, le bouton « Obtenir » sera activé quand la boutique sera en ligne"
"Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)",
"Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la",
"Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire"
],
"tips": [
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, changement de machine), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
"En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés",
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste"
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste",
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite"
]
}
},
@ -1231,13 +1234,13 @@
"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",
"premiumNotForSale": "Premium n'est pas encore offert en ligne.",
"ctaHaveKey": "J'ai déjà une clé"
},
"account": {
"title": "Compte Maximus",
"optional": "Optionnel",
"description": "Connectez-vous pour accéder aux fonctionnalités Premium (version web, synchronisation). Le compte est requis uniquement pour les fonctionnalités Premium.",
"description": "Le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions. Les éditions Base et Premium viennent de la clé de licence, pas du compte.",
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"connected": "Connecté",

View file

@ -113,7 +113,7 @@ describe("upsell i18n coverage (fr + en)", () => {
title: string;
features: Record<string, string>;
ctaGet: string;
ctaGetSoon: string;
premiumNotForSale: string;
ctaHaveKey: string;
};
}
@ -142,12 +142,26 @@ describe("upsell i18n coverage (fr + en)", () => {
Object.values(locales).forEach((messages) => {
expect(messages.upsell.title).toBeTruthy();
expect(messages.upsell.ctaGet).toBeTruthy();
expect(messages.upsell.ctaGetSoon).toBeTruthy();
expect(messages.upsell.premiumNotForSale).toBeTruthy();
expect(messages.upsell.ctaHaveKey).toBeTruthy();
expect(messages.nav.locked).toBeTruthy();
});
});
// Pinning the whole key set catches a stale CTA key coming back in either
// locale (the Premium note replaced the "coming soon" note in #346).
it("has exactly the upsell keys the locked screen uses, in both locales", () => {
Object.entries(locales).forEach(([lng, messages]) => {
expect(Object.keys(messages.upsell).sort(), lng).toEqual([
"ctaGet",
"ctaHaveKey",
"features",
"premiumNotForSale",
"title",
]);
});
});
it("keeps the license.editions.<tier> label keys required by the CTA", () => {
Object.values(locales).forEach((messages) => {
expect(messages.license.editions.base).toBeTruthy();
@ -155,3 +169,42 @@ describe("upsell i18n coverage (fr + en)", () => {
});
});
});
// Texts decided in planning (#346): the Premium note (D3), the Compte Maximus
// card (D4) and the download link of the free edition (D11), pinned verbatim.
describe("purchase path texts (fr + en)", () => {
interface PurchaseTexts {
upsell: { premiumNotForSale: string };
account: { description: string };
settings: { updates: { downloadLatest: string; notEntitled: string } };
}
const texts = { fr, en } as unknown as Record<"fr" | "en", PurchaseTexts>;
it("says Premium is not sold online yet", () => {
expect(texts.fr.upsell.premiumNotForSale).toBe("Premium n'est pas encore offert en ligne.");
expect(texts.en.upsell.premiumNotForSale).toBe("Premium isn't sold online yet.");
});
it("describes what the account is for, without promising Premium", () => {
expect(texts.fr.account.description).toBe(
"Le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions. Les éditions Base et Premium viennent de la clé de licence, pas du compte.",
);
expect(texts.en.account.description).toBe(
"Your account lets you find your licenses on lacompagniemaximus.com and identifies you in the feedback you send. Base and Premium editions come from your license key, not from the account.",
);
});
it("labels the download link of the free edition", () => {
expect(texts.fr.settings.updates.downloadLatest).toBe("Télécharger la dernière version");
expect(texts.en.settings.updates.downloadLatest).toBe("Download the latest version");
});
it("tells the free edition that automatic updates come with Base", () => {
expect(texts.fr.settings.updates.notEntitled).toBe(
"Les mises à jour automatiques sont incluses dans l'édition Base.",
);
expect(texts.en.settings.updates.notEntitled).toBe(
"Automatic updates are included in the Base edition.",
);
});
});

View file

@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { upsellCtaFor } from "./upsellCta";
import { ENTITLEMENTS, requiredTierFor, type FeatureKey } from "./entitlements";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
// No jsdom here: this pure rule is the only automated proof of what a locked
// screen offers (issue #346, decision A1).
describe("upsellCtaFor", () => {
it("makes Base purchasable, with no note", () => {
expect(upsellCtaFor("base")).toEqual({ purchasable: true, noteKey: null });
});
it("keeps Premium disabled, with the not-for-sale note", () => {
expect(upsellCtaFor("premium")).toEqual({
purchasable: false,
noteKey: "upsell.premiumNotForSale",
});
});
it("offers nothing for free, which no feature requires", () => {
expect(upsellCtaFor("free")).toEqual({ purchasable: false, noteKey: null });
});
});
// The same rule, walked through the real matrix: what each locked screen shows.
describe("upsellCtaFor on every gated feature", () => {
const features = Object.keys(ENTITLEMENTS) as FeatureKey[];
const resolve = (messages: unknown, key: string): unknown =>
key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object" ? (node as Record<string, unknown>)[part] : undefined,
messages,
);
it("sells Base on the Budget, Adjustments, advanced reports and extra profile screens", () => {
for (const f of ["budget", "adjustments", "reports-advanced", "multi-profile"] as FeatureKey[]) {
expect(upsellCtaFor(requiredTierFor(f)), f).toEqual({ purchasable: true, noteKey: null });
}
});
it("keeps the Balance screen (Premium) disabled with its note", () => {
expect(upsellCtaFor(requiredTierFor("balance"))).toEqual({
purchasable: false,
noteKey: "upsell.premiumNotForSale",
});
});
it("covers every feature of the matrix, with notes that exist in both locales", () => {
expect(features.sort()).toEqual(
["adjustments", "balance", "budget", "multi-profile", "reports-advanced"].sort(),
);
for (const f of features) {
const { noteKey } = upsellCtaFor(requiredTierFor(f));
if (noteKey === null) continue;
for (const [lng, messages] of Object.entries({ fr, en })) {
const value = resolve(messages, noteKey);
expect(typeof value, `${noteKey} in ${lng}`).toBe("string");
expect((value as string).trim(), `${noteKey} in ${lng}`).not.toBe("");
}
}
});
});

29
src/shared/upsellCta.ts Normal file
View file

@ -0,0 +1,29 @@
import type { Edition } from "../services/licenseService";
export interface UpsellCta {
/** Whether the "Get <tier>" button opens the product page. */
purchasable: boolean;
/** Note shown under a disabled button, or null. */
noteKey: string | null;
}
/**
* What a locked screen offers for the tier it requires (issue #346). Only Base
* is sold online (the site's product page sells the Base license); Premium
* keeps a disabled button with an honest note until an offer exists.
*/
export function upsellCtaFor(tier: Edition): UpsellCta {
switch (tier) {
case "base":
return { purchasable: true, noteKey: null };
case "premium":
// Before making Premium purchasable here: PurchaseButton sells Base only
// (its label and purchaseUrl know a single product page), so the Balance
// screen would offer "Get Base". Give the button the tier and a Premium
// product URL first.
return { purchasable: false, noteKey: "upsell.premiumNotForSale" };
default:
// No feature requires the free edition (see requiredTierFor).
return { purchasable: false, noteKey: null };
}
}