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
Owner

Fixes #346

En édition gratuite, les écrans verrouillés affichaient « Obtenir {tier} » désactivé avec « Achat en ligne bientôt disponible ». Maintenant que #270 a livré purchaseUrl, le bouton Base devient actif partout. Premium reçoit une note honnête, la Gratuite gagne un chemin d'achat et un lien vers la dernière version, et la carte Compte cesse de promettre des fonctionnalités inexistantes. Deuxième maillon de planned-2026-09-24-paiements.

Ce qui change

  • Règle : src/shared/upsellCta.ts, upsellCtaFor(tier) → { purchasable, noteKey }. Base est achetable ; Premium reste désactivé avec upsell.premiumNotForSale (décision A1).
  • CTA partagés, un composant par fichier (règle du CLAUDE.md, d'où trois fichiers au lieu du seul PurchaseButton.tsx de l'issue) :
    • PurchaseButton (« Obtenir Base ») et DownloadLatestButton (« Télécharger la dernière version ») ouvrent purchaseUrl dans le navigateur, sans routeur ;
    • HaveKeyButton ne rend rien hors BrowserRouter, où useNavigate lèverait une erreur.
  • UpsellGate : il utilise la règle et les CTA. Le lien d'achat n'appelle jamais onNavigate, donc le modal hôte reste ouvert ; seul « J'ai déjà une clé » l'appelle.
  • ProfileFormModal : il réutilise UpsellGate au lieu de son panneau compact, garde son bouton Annuler (retour à la liste des profils) et se ferme après « J'ai déjà une clé » (onNavigate={onClose}).
    • Ouvert depuis ProfileSwitcher (dans le routeur), il affiche « J'ai déjà une clé ».
    • Ouvert depuis ProfileSelectionPage (hors routeur), il ne l'affiche pas, comme avant.
  • Carte Mises à jour (Gratuite) : « Obtenir Base », « J'ai déjà une clé », « Télécharger la dernière version ».
  • Page d'erreur (Gratuite, hors routeur) : « Obtenir Base » et « Télécharger la dernière version ».
  • Aucune vérification de mise à jour ni aucun appel réseau n'est ajouté en Gratuite.
  • i18n FR/EN :
    • texte exact de la carte Compte (D4) ;
    • note Premium (D3) ;
    • settings.updates.notEntitled reformulé et downloadLatest ajouté ;
    • étape et conseil de docs.editions ;
    • la clé de la note « bientôt » est retirée.
  • Documentation :
    • ADR 0017 : amendement en tête, et renvois aux l.66, 70 et 111 ainsi qu'aux liens ;
    • architecture.md : ligne UpsellGate, CTA partagés, upsellCtaFor, ligne multi-profils, comptes (12 composants partagés) et liste de src/shared/ ;
    • guide §12 ;
    • CHANGELOG FR/EN, Added et Changed placés avant Fixed et Security.
  • Carte Licence : non touchée, elle garde son bouton « Acheter Simpl'Résultat » (décision A2).

Tests

Suite Avant Après
vitest 1246 1257 (+11 : upsellCta 6, entitlements 5)
npm run build vert vert

Pas de modification Rust, donc check-rust ne tourne pas.

  • upsellCtaFor est testée seule, puis sur toute la matrice. Budget, Ajustements, rapports avancés et multi-profils donnent un achat possible sans note ; le Bilan donne un bouton désactivé avec sa note, et cette note existe en FR comme en EN.
  • entitlements.test.ts épingle le jeu exact de clés upsell par langue, ce qui rattraperait le retour d'une clé périmée sans jamais la nommer. Il épingle aussi mot pour mot les textes décidés : note Premium, carte Compte, libellé de téléchargement, texte notEntitled.
  • Sans jsdom, rien ne teste le rendu des composants eux-mêmes ; la règle et les textes sont la partie prouvée.

Preuve du rouge

Chaque mutation est appliquée depuis une copie de sauvegarde puis restaurée ; le diffstat est identique avant et après, et la suite revient à 27/27 verts.

Mutation Tests rouges
Premium rendu achetable dans upsellCtaFor 2
Bilan vendu comme Base dans la matrice 4
ancienne clé de note remise dans une langue 1
texte de la carte Compte altéré 1
libellé de téléchargement altéré 1

À noter

  • L'onboarding (#355, #357) touche aussi le CHANGELOG, le guide, architecture.md et les fichiers i18n. Selon l'ordre de merge, attendez-vous à des conflits textuels simples.
  • Plan vérifié par le plan checker en 2 itérations. La 1re signalait qu'un test « clé absente » aurait réécrit le nom de la clé dans le fichier de test, ce que le critère d'acceptation interdit ; d'où l'épinglage du jeu de clés.

🤖 Generated with Claude Code

Fixes #346 En édition gratuite, les écrans verrouillés affichaient « Obtenir {tier} » désactivé avec « Achat en ligne bientôt disponible ». Maintenant que #270 a livré `purchaseUrl`, le bouton Base devient actif partout. Premium reçoit une note honnête, la Gratuite gagne un chemin d'achat et un lien vers la dernière version, et la carte Compte cesse de promettre des fonctionnalités inexistantes. Deuxième maillon de `planned-2026-09-24-paiements`. ## Ce qui change - **Règle** : `src/shared/upsellCta.ts`, `upsellCtaFor(tier)` → `{ purchasable, noteKey }`. Base est achetable ; Premium reste désactivé avec `upsell.premiumNotForSale` (décision A1). - **CTA partagés**, un composant par fichier (règle du CLAUDE.md, d'où trois fichiers au lieu du seul `PurchaseButton.tsx` de l'issue) : - `PurchaseButton` (« Obtenir Base ») et `DownloadLatestButton` (« Télécharger la dernière version ») ouvrent `purchaseUrl` dans le navigateur, sans routeur ; - `HaveKeyButton` ne rend rien hors `BrowserRouter`, où `useNavigate` lèverait une erreur. - **`UpsellGate`** : il utilise la règle et les CTA. Le lien d'achat n'appelle jamais `onNavigate`, donc le modal hôte reste ouvert ; seul « J'ai déjà une clé » l'appelle. - **`ProfileFormModal`** : il réutilise `UpsellGate` au lieu de son panneau compact, garde son bouton Annuler (retour à la liste des profils) et se ferme après « J'ai déjà une clé » (`onNavigate={onClose}`). - Ouvert depuis `ProfileSwitcher` (dans le routeur), il affiche « J'ai déjà une clé ». - Ouvert depuis `ProfileSelectionPage` (hors routeur), il ne l'affiche pas, comme avant. - **Carte Mises à jour** (Gratuite) : « Obtenir Base », « J'ai déjà une clé », « Télécharger la dernière version ». - **Page d'erreur** (Gratuite, hors routeur) : « Obtenir Base » et « Télécharger la dernière version ». - Aucune vérification de mise à jour ni aucun appel réseau n'est ajouté en Gratuite. - **i18n FR/EN** : - texte exact de la carte Compte (D4) ; - note Premium (D3) ; - `settings.updates.notEntitled` reformulé et `downloadLatest` ajouté ; - étape et conseil de `docs.editions` ; - la clé de la note « bientôt » est retirée. - **Documentation** : - ADR 0017 : amendement en tête, et renvois aux l.66, 70 et 111 ainsi qu'aux liens ; - `architecture.md` : ligne `UpsellGate`, CTA partagés, `upsellCtaFor`, ligne multi-profils, comptes (12 composants partagés) et liste de `src/shared/` ; - guide §12 ; - CHANGELOG FR/EN, Added et Changed placés avant Fixed et Security. - **Carte Licence** : non touchée, elle garde son bouton « Acheter Simpl'Résultat » (décision A2). ## Tests | Suite | Avant | Après | |---|---|---| | vitest | 1246 | **1257** (+11 : `upsellCta` 6, `entitlements` 5) | | `npm run build` | vert | vert | Pas de modification Rust, donc `check-rust` ne tourne pas. - `upsellCtaFor` est testée seule, puis sur toute la matrice. Budget, Ajustements, rapports avancés et multi-profils donnent un achat possible sans note ; le Bilan donne un bouton désactivé avec sa note, et cette note existe en FR comme en EN. - `entitlements.test.ts` épingle le jeu exact de clés `upsell` par langue, ce qui rattraperait le retour d'une clé périmée sans jamais la nommer. Il épingle aussi mot pour mot les textes décidés : note Premium, carte Compte, libellé de téléchargement, texte `notEntitled`. - Sans jsdom, rien ne teste le rendu des composants eux-mêmes ; la règle et les textes sont la partie prouvée. ## Preuve du rouge Chaque mutation est appliquée depuis une copie de sauvegarde puis restaurée ; le diffstat est identique avant et après, et la suite revient à 27/27 verts. | Mutation | Tests rouges | |---|---| | Premium rendu achetable dans `upsellCtaFor` | 2 | | Bilan vendu comme Base dans la matrice | 4 | | ancienne clé de note remise dans une langue | 1 | | texte de la carte Compte altéré | 1 | | libellé de téléchargement altéré | 1 | ## À noter - L'onboarding (#355, #357) touche aussi le CHANGELOG, le guide, `architecture.md` et les fichiers i18n. Selon l'ordre de merge, attendez-vous à des conflits textuels simples. - Plan vérifié par le plan checker en 2 itérations. La 1re signalait qu'un test « clé absente » aurait réécrit le nom de la clé dans le fichier de test, ce que le critère d'acceptation interdit ; d'où l'épinglage du jeu de clés. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-09-27 19:51:16 +00:00
feat(upsell): activate "Get Base", honest Premium note, purchase path in Free, Account card text
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
57a0fbc2f7
- upsellCtaFor(tier) decides what a locked screen offers: Base is
  purchasable, Premium stays disabled with "Premium isn't sold online
  yet". Pure and tested on the whole entitlements matrix.
- Shared CTAs, one component per file: PurchaseButton ("Get Base") and
  DownloadLatestButton open the localized product page (purchaseUrl) and
  need no router; HaveKeyButton renders only inside BrowserRouter.
- UpsellGate uses them; the purchase link never calls onNavigate, so
  modal hosts stay open. ProfileFormModal reuses UpsellGate instead of
  its compact panel, keeps its Cancel button, and closes after "I
  already have a key" (onNavigate={onClose}).
- In Free, the Updates card offers Get Base, I already have a key and
  Download the latest version; the error page (outside the router)
  offers Get Base and the download link. No update check, no network
  call.
- i18n FR/EN: Account card text, Premium note, Updates notEntitled text,
  download label, editions guide step and tip; the "coming soon" note
  key is gone. Tests pin the upsell key set and the decided texts.
- Docs: ADR 0017 amendment, architecture (UpsellGate row, shared CTAs,
  upsellCtaFor, multi-profile row, counts), user guide section 12;
  CHANGELOG FR/EN.

Proven red: Premium made purchasable 2, balance sold as Base in the
matrix 4, the old note key back in a locale 1, Account text altered 1,
download label altered 1.

Fixes #346

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

CI verte au head 57a0fbc, vérifiée dans les logs : run 429 (check-frontend), HEAD is now at 57a0fbc, build OK, 64 fichiers, 1257 tests vitest (comme en local). check-rust ne s'est pas déclenché : aucun fichier sous src-tauri/ n'est touché.

CI verte au head `57a0fbc`, vérifiée dans les logs : run 429 (`check-frontend`), `HEAD is now at 57a0fbc`, build OK, 64 fichiers, **1257** tests vitest (comme en local). `check-rust` ne s'est pas déclenché : aucun fichier sous `src-tauri/` n'est touché.
Author
Owner

Verdict : APPROVE

Résumé. Le code fait ce que l'issue demande, et chaque critère d'acceptation se vérifie à la lecture. Les trois mutations annoncées se reproduisent au chiffre près. En revanche, le câblage des composants n'est gardé par aucun test, alors qu'il peut l'être sans jsdom : cinq mutations du relecteur sur six passent la suite sans un rouge, et une sonde de rendu en attrape cinq (suggestion 1).

Vérifié

  • CI : run 429 sur 57a0fbc, log lu. vite build passe, puis 64 fichiers et 1257 tests verts. En local, sur une copie extraite de la tête (git archive), npm run build et vitest run sont verts, 1257/1257.
  • Mutations de l'auteur, rejouées sur la suite complète :
    • Premium rendu achetable → 2 rouges ;
    • Bilan vendu comme Base dans la matrice → 4 ;
    • ctaGetSoon remis dans en.json → 1, le test du jeu de clés, qui ne nomme pas la clé.
  • Placement des routeurs : ProfileSelectionPage (App.tsx:97) et ErrorPage (App.tsx:93, ErrorBoundary.tsx:29) sont rendues hors du BrowserRouter monté en App.tsx:109, et main.tsx n'en monte aucun. HaveKeyButton appelle useInRouterContext() sans condition et ne monte useNavigate que dans le sous-composant : le patron est correct.
  • Critères d'acceptation, relus un par un :
    • UpsellGate suit upsellCtaFor ;
    • ProfileFormModal n'a plus de panneau propre, garde Annuler et passe onNavigate={onClose} ;
    • la carte Mises à jour et la page d'erreur portent les CTA prévus ;
    • les textes D3, D4 et D11 sont exacts mot pour mot ;
    • ctaGetSoon n'apparaît plus nulle part (git grep vide).
  • Ouverture du navigateur : opener:default autorise les URL https, et l'appel est celui de LicenseCard.tsx:40.
  • Page produit en prod : en FR comme en EN, elle sert les installateurs v0.15.0 (…_x64-setup.exe, …_amd64.deb). « Télécharger la dernière version » dit donc vrai.
  • Documentation et commit :
    • le compte de architecture.md est exact : 12 composants dans components/shared/, contre 9 sur la base ;
    • l'amendement de l'ADR 0017 suit la convention de l'ADR 0018 ;
    • le CHANGELOG suit l'ordre Added / Changed / Fixed / Security ;
    • Fixes #346 figure dans le message de commit.

Suggestions (non bloquantes)

1. Le câblage des CTA n'est gardé par aucun test, et il peut l'être sans jsdom.

La prémisse « pas de jsdom, donc seules des fonctions pures se testent » vient de l'issue, et la PR la reprend. Elle ne tient pas :

  • renderToStaticMarkup (react-dom/server) rend dans l'environnement node de vitest ;
  • FilterPanel.test.tsx (#274) appelle déjà un composant comme une fonction pour lire ses props.

J'ai écrit une sonde de 6 tests sur une copie de la tête. Elle passe sur 57a0fbc : 1263/1263, tsc propre. Chaque mutation ci-dessous est appliquée puis restaurée, et le fichier est comparé octet par octet à la tête.

Mutation (relecteur) Suite de la PR Sonde
UpsellGate ignore la règle ({true ? (, l.57) : « Obtenir Base » actif sur le Bilan 0 / 1257 1 rouge
garde useInRouterContext retirée de HaveKeyButton (l.27) 0 / 1257 1 rouge : useNavigate() may be used only in the context of a <Router> component.
PurchaseButton ouvre purchaseUrl("fr") en dur (l.26) 0 / 1257 1 rouge
UpsellGate ne rend plus la note Premium (l.69) 0 / 1257 1 rouge
UpsellGate ne transmet plus onNavigate à HaveKeyButton (l.75) 0 / 1257 1 rouge
ProfileFormModal ne passe plus onNavigate={onClose} (l.189) 0 / 1257 0

Deux lignes méritent un mot :

  • La 2e est le plantage même qui justifiait l'ancien panneau compact. En Gratuite, ProfileSelectionPage → Gérer → Créer finirait sur l'ErrorBoundary.
  • La 6e demande un clic, puisque le mode upsell est un état interne du modal. Elle reste à vérifier à la main dans l'app : ouvrir le modal depuis ProfileSwitcher, cliquer « J'ai déjà une clé » ; le modal doit se fermer sur la carte Licence.
Sonde proposée : src/components/shared/UpsellCtas.render.test.tsx
// 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) }));

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 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();
  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");
  });
});

describe("PurchaseButton", () => {
  it("opens the product page in the app language", () => {
    language = "en";
    const button = PurchaseButton({}) as ReactElement<{ onClick: () => void }>;
    button.props.onClick();
    expect(openUrl).toHaveBeenCalledWith("https://lacompagniemaximus.com/en/produits/simpl-resultat");
  });
});

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");
  });
});

2. Le texte de la carte Compte est exact en pratique, pas dans le code.

Le texte D4 dit : « Les éditions Base et Premium viennent de la clé de licence, pas du compte. » Or current_entitlements accorde encore Premium à un compte dont le cache signé porte subscription_status: "active" (license_commands.rs:290-296 et :353-358). L'ADR 0017 le dit aussi en §Contexte : « ou depuis un abonnement Compte Maximus pour Premium ».

Aucun abonnement n'est vendu, donc rien n'atteint ce chemin aujourd'hui. Le jour où un abonnement existera, le texte redeviendra faux en silence, comme celui de v0.7. Deux options :

  • noter la réserve dans l'amendement de l'ADR ;
  • ouvrir une issue pour retirer ce chemin dormant.

3. PurchaseButton vend Base en dur.

UpsellGate rend <PurchaseButton /> dès que cta.purchasable est vrai, sans lui passer le palier. Si upsellCtaFor("premium") bascule un jour, l'écran du Bilan affichera « Obtenir Base » et ouvrira la page de Base : celui qui fera ce changement modifiera le test Premium, pas le bouton. Deux options :

  • passer tier au bouton ;
  • ajouter une ligne de commentaire dans upsellCtaFor.

4. Un openUrl en échec ne laisse aucune trace.

void openUrl(...) figure dans PurchaseButton.tsx:26 et DownloadLatestButton.tsx:27, comme déjà dans LicenseCard.tsx:40. Si l'appel échoue, par exemple sous Linux sans xdg-open, le bouton ne fait rien. initLogCapture capte console.error mais pas unhandledrejection, donc rien n'arrive dans Journaux. Un .catch((e) => logError("openUrl failed", e)) suffit.

5. Rédaction

  • Guide §12, puce « Obtenir Base » : sur l'écran du Bilan, le bouton désactivé s'intitule « Obtenir Premium ».
  • Étape 4 du guide et docs.editions.steps[3] FR/EN : l'achat arrive en queue de « Les modules se déverrouillent immédiatement », donc après la saisie de la clé. Une étape à part, placée avant l'étape 3, suivrait l'ordre réel.
  • CHANGELOG EN :
    • « The Compte Maximus card » : l'interface anglaise dit « Maximus Account » (account.title) ;
    • « from every locked screen » : l'écran du Bilan ne vend rien.
  • CHANGELOG FR : « au lieu de « Achat en ligne… » » s'écrit « au lieu d'« Achat en ligne… » ».

Hors code

Le checkout du site reste en mode test tant que la-compagnie-maximus#141 et maximus-api#62 sont ouvertes. #141 porte déjà « release desktop re-gated » : aucune release ne doit embarquer ce bouton actif avant.

## Verdict : APPROVE **Résumé.** Le code fait ce que l'issue demande, et chaque critère d'acceptation se vérifie à la lecture. Les trois mutations annoncées se reproduisent au chiffre près. En revanche, le câblage des composants n'est gardé par aucun test, alors qu'il peut l'être sans jsdom : cinq mutations du relecteur sur six passent la suite sans un rouge, et une sonde de rendu en attrape cinq (suggestion 1). ### Vérifié - **CI** : run 429 sur `57a0fbc`, log lu. `vite build` passe, puis 64 fichiers et **1257** tests verts. En local, sur une copie extraite de la tête (`git archive`), `npm run build` et `vitest run` sont verts, 1257/1257. - **Mutations de l'auteur, rejouées** sur la suite complète : - Premium rendu achetable → **2** rouges ; - Bilan vendu comme Base dans la matrice → **4** ; - `ctaGetSoon` remis dans `en.json` → **1**, le test du jeu de clés, qui ne nomme pas la clé. - **Placement des routeurs** : `ProfileSelectionPage` (`App.tsx:97`) et `ErrorPage` (`App.tsx:93`, `ErrorBoundary.tsx:29`) sont rendues hors du `BrowserRouter` monté en `App.tsx:109`, et `main.tsx` n'en monte aucun. `HaveKeyButton` appelle `useInRouterContext()` sans condition et ne monte `useNavigate` que dans le sous-composant : le patron est correct. - **Critères d'acceptation**, relus un par un : - `UpsellGate` suit `upsellCtaFor` ; - `ProfileFormModal` n'a plus de panneau propre, garde Annuler et passe `onNavigate={onClose}` ; - la carte Mises à jour et la page d'erreur portent les CTA prévus ; - les textes D3, D4 et D11 sont exacts mot pour mot ; - `ctaGetSoon` n'apparaît plus nulle part (`git grep` vide). - **Ouverture du navigateur** : `opener:default` autorise les URL `https`, et l'appel est celui de `LicenseCard.tsx:40`. - **Page produit en prod** : en FR comme en EN, elle sert les installateurs v0.15.0 (`…_x64-setup.exe`, `…_amd64.deb`). « Télécharger la dernière version » dit donc vrai. - **Documentation et commit** : - le compte de `architecture.md` est exact : 12 composants dans `components/shared/`, contre 9 sur la base ; - l'amendement de l'ADR 0017 suit la convention de l'ADR 0018 ; - le CHANGELOG suit l'ordre Added / Changed / Fixed / Security ; - `Fixes #346` figure dans le message de commit. ### Suggestions (non bloquantes) **1. Le câblage des CTA n'est gardé par aucun test, et il peut l'être sans jsdom.** La prémisse « pas de jsdom, donc seules des fonctions pures se testent » vient de l'issue, et la PR la reprend. Elle ne tient pas : - `renderToStaticMarkup` (`react-dom/server`) rend dans l'environnement node de vitest ; - `FilterPanel.test.tsx` (#274) appelle déjà un composant comme une fonction pour lire ses props. J'ai écrit une sonde de 6 tests sur une copie de la tête. Elle passe sur `57a0fbc` : 1263/1263, `tsc` propre. Chaque mutation ci-dessous est appliquée puis restaurée, et le fichier est comparé octet par octet à la tête. | Mutation (relecteur) | Suite de la PR | Sonde | |---|---|---| | `UpsellGate` ignore la règle (`{true ? (`, l.57) : « Obtenir Base » actif sur le Bilan | 0 / 1257 | 1 rouge | | garde `useInRouterContext` retirée de `HaveKeyButton` (l.27) | 0 / 1257 | 1 rouge : `useNavigate() may be used only in the context of a <Router> component.` | | `PurchaseButton` ouvre `purchaseUrl("fr")` en dur (l.26) | 0 / 1257 | 1 rouge | | `UpsellGate` ne rend plus la note Premium (l.69) | 0 / 1257 | 1 rouge | | `UpsellGate` ne transmet plus `onNavigate` à `HaveKeyButton` (l.75) | 0 / 1257 | 1 rouge | | `ProfileFormModal` ne passe plus `onNavigate={onClose}` (l.189) | 0 / 1257 | 0 | Deux lignes méritent un mot : - **La 2e** est le plantage même qui justifiait l'ancien panneau compact. En Gratuite, `ProfileSelectionPage` → Gérer → Créer finirait sur l'ErrorBoundary. - **La 6e** demande un clic, puisque le mode `upsell` est un état interne du modal. Elle reste à vérifier à la main dans l'app : ouvrir le modal depuis `ProfileSwitcher`, cliquer « J'ai déjà une clé » ; le modal doit se fermer sur la carte Licence. <details><summary>Sonde proposée : <code>src/components/shared/UpsellCtas.render.test.tsx</code></summary> ```tsx // 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) })); 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 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(); 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"); }); }); describe("PurchaseButton", () => { it("opens the product page in the app language", () => { language = "en"; const button = PurchaseButton({}) as ReactElement<{ onClick: () => void }>; button.props.onClick(); expect(openUrl).toHaveBeenCalledWith("https://lacompagniemaximus.com/en/produits/simpl-resultat"); }); }); 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"); }); }); ``` </details> **2. Le texte de la carte Compte est exact en pratique, pas dans le code.** Le texte D4 dit : « Les éditions Base et Premium viennent de la clé de licence, pas du compte. » Or `current_entitlements` accorde encore Premium à un compte dont le cache signé porte `subscription_status: "active"` (`license_commands.rs:290-296` et `:353-358`). L'ADR 0017 le dit aussi en §Contexte : « ou depuis un abonnement Compte Maximus pour Premium ». Aucun abonnement n'est vendu, donc rien n'atteint ce chemin aujourd'hui. Le jour où un abonnement existera, le texte redeviendra faux en silence, comme celui de v0.7. Deux options : - noter la réserve dans l'amendement de l'ADR ; - ouvrir une issue pour retirer ce chemin dormant. **3. `PurchaseButton` vend Base en dur.** `UpsellGate` rend `<PurchaseButton />` dès que `cta.purchasable` est vrai, sans lui passer le palier. Si `upsellCtaFor("premium")` bascule un jour, l'écran du Bilan affichera « Obtenir Base » et ouvrira la page de Base : celui qui fera ce changement modifiera le test Premium, pas le bouton. Deux options : - passer `tier` au bouton ; - ajouter une ligne de commentaire dans `upsellCtaFor`. **4. Un `openUrl` en échec ne laisse aucune trace.** `void openUrl(...)` figure dans `PurchaseButton.tsx:26` et `DownloadLatestButton.tsx:27`, comme déjà dans `LicenseCard.tsx:40`. Si l'appel échoue, par exemple sous Linux sans `xdg-open`, le bouton ne fait rien. `initLogCapture` capte `console.error` mais pas `unhandledrejection`, donc rien n'arrive dans Journaux. Un `.catch((e) => logError("openUrl failed", e))` suffit. **5. Rédaction** - **Guide §12, puce « Obtenir Base »** : sur l'écran du Bilan, le bouton désactivé s'intitule « Obtenir Premium ». - **Étape 4 du guide et `docs.editions.steps[3]` FR/EN** : l'achat arrive en queue de « Les modules se déverrouillent immédiatement », donc après la saisie de la clé. Une étape à part, placée avant l'étape 3, suivrait l'ordre réel. - **CHANGELOG EN** : - « The Compte Maximus card » : l'interface anglaise dit « Maximus Account » (`account.title`) ; - « from every locked screen » : l'écran du Bilan ne vend rien. - **CHANGELOG FR** : « au lieu de « Achat en ligne… » » s'écrit « au lieu d'« Achat en ligne… » ». ### Hors code Le checkout du site reste en mode test tant que la-compagnie-maximus#141 et maximus-api#62 sont ouvertes. #141 porte déjà « release desktop re-gated » : aucune release ne doit embarquer ce bouton actif avant.
maximus referenced this pull request from a commit 2026-09-28 00:47:14 +00:00
maximus added 1 commit 2026-09-28 00:47:14 +00:00
fix(upsell): apply the review of #362
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m39s
170b22d356
- Render tests for the upsell CTAs (UpsellCtas.render.test.tsx): the
  reviewer's probe, which needs no jsdom (react-dom/server in the node
  environment, plus the FilterPanel.test.tsx pattern of calling a
  component as a function). It pins the Base/Premium CTA on UpsellGate,
  the router guard of HaveKeyButton, the localized product URL, the
  Premium note and the onNavigate forwarding, none of which the suite
  guarded before.
- A failure to open the browser (openUrl) is now logged through
  logError in PurchaseButton, DownloadLatestButton and LicenseCard,
  instead of vanishing as an unhandled rejection that never reaches the
  in-app logs. Tested for both shared buttons.
- upsellCtaFor: comment the trap in making Premium purchasable, since
  PurchaseButton sells Base only.
- ADR 0017 amendment: the Account card text holds in practice only;
  current_entitlements still grants Premium to an active subscription,
  a dormant path until a subscription is sold.
- User guide and in-app docs: the purchase step now comes before the
  key entry, and the Balance button is named "Get Premium".
- CHANGELOG: "Maximus Account" in English, "every screen that requires
  it" in both files (the Balance screen sells nothing), and the French
  elision before a guillemet.

Refs #346

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

Suggestions de la revue appliquées : 170b22d

Les cinq suggestions sont appliquées dans un seul commit, fix(upsell): apply the review of #362.

  1. Tests de rendu : la sonde du relecteur devient src/components/shared/UpsellCtas.render.test.tsx, étendue à DownloadLatestButton et à la journalisation des échecs, soit 9 tests.
  2. Carte Compte : la réserve est notée dans l'amendement de l'ADR 0017 (check_account_edition, chemin dormant tant qu'aucun abonnement n'est vendu).
  3. PurchaseButton : un commentaire dans upsellCtaFor signale qu'avant de rendre Premium achetable, il faut donner au bouton le palier et une URL Premium. Le palier n'est pas passé au bouton dès maintenant, parce que purchaseUrl ne connaît qu'une page produit.
  4. openUrl en échec : .catch vers logError dans PurchaseButton, DownloadLatestButton et LicenseCard. LicenseCard n'a pas de test, car ses hooks empêchent de l'appeler comme une fonction.
  5. Rédaction : dans le guide et les docs intégrées, l'achat devient l'étape 3, avant la saisie de la clé, et le bouton du Bilan est nommé « Obtenir Premium ». Dans les CHANGELOG : « Maximus Account » en anglais, « chaque écran qui la requiert » dans les deux fichiers (le Bilan ne vend rien), élision en français.

Mutations rejouées sur la tête finale

Worktree jetable à 170b22d, npm ci, suite complète pour chaque mutation, puis fichier restauré et comparé octet par octet. Base : 0 rouge sur 1266, code de sortie 0.

Mutation Rouges / 1266
Premium rendu achetable (upsellCtaFor) 3
Bilan vendu comme Base dans la matrice 4
ctaGetSoon remis dans en.json 1
UpsellGate ignore la règle 1
garde de routeur retirée de HaveKeyButton 1
ProfileFormModal ne passe plus onNavigate={onClose} 0 (demande un clic, voir plus bas)
URL d'achat figée en français (PurchaseButton) 1
note Premium absente 1
onNavigate non transmis à HaveKeyButton 1
échec d'openUrl non journalisé (PurchaseButton) 1
échec d'openUrl non journalisé (DownloadLatestButton) 1
URL figée en français (DownloadLatestButton) 1

Chaque mutation rouge sort avec le code 1.

CI

Run 430 (check-frontend) vert sur 170b22d, log lu : HEAD is now at 170b22d, build OK, 65 fichiers, 1266 tests (1257 + 9). check-rust ne s'est pas déclenché, à raison : aucun fichier sous src-tauri/.

Reste à vérifier à la main

En édition Gratuite, ouvrir le modal de création de profil depuis ProfileSwitcher et cliquer « J'ai déjà une clé » : le modal doit se fermer sur la carte Licence.

Avant toute release

Le checkout du site reste en mode test tant que la-compagnie-maximus#141 et maximus-api#62 sont ouvertes : aucun tag ne doit embarquer ce bouton actif avant. La contrainte est consignée dans STATE.md au merge.

## Suggestions de la revue appliquées : `170b22d` Les cinq suggestions sont appliquées dans un seul commit, `fix(upsell): apply the review of #362`. 1. **Tests de rendu** : la sonde du relecteur devient `src/components/shared/UpsellCtas.render.test.tsx`, étendue à `DownloadLatestButton` et à la journalisation des échecs, soit 9 tests. 2. **Carte Compte** : la réserve est notée dans l'amendement de l'ADR 0017 (`check_account_edition`, chemin dormant tant qu'aucun abonnement n'est vendu). 3. **`PurchaseButton`** : un commentaire dans `upsellCtaFor` signale qu'avant de rendre Premium achetable, il faut donner au bouton le palier et une URL Premium. Le palier n'est pas passé au bouton dès maintenant, parce que `purchaseUrl` ne connaît qu'une page produit. 4. **`openUrl` en échec** : `.catch` vers `logError` dans `PurchaseButton`, `DownloadLatestButton` et `LicenseCard`. `LicenseCard` n'a pas de test, car ses hooks empêchent de l'appeler comme une fonction. 5. **Rédaction** : dans le guide et les docs intégrées, l'achat devient l'étape 3, avant la saisie de la clé, et le bouton du Bilan est nommé « Obtenir Premium ». Dans les CHANGELOG : « Maximus Account » en anglais, « chaque écran qui la requiert » dans les deux fichiers (le Bilan ne vend rien), élision en français. ### Mutations rejouées sur la tête finale Worktree jetable à `170b22d`, `npm ci`, suite complète pour chaque mutation, puis fichier restauré et comparé octet par octet. Base : 0 rouge sur 1266, code de sortie 0. | Mutation | Rouges / 1266 | |---|---| | Premium rendu achetable (`upsellCtaFor`) | 3 | | Bilan vendu comme Base dans la matrice | 4 | | `ctaGetSoon` remis dans `en.json` | 1 | | `UpsellGate` ignore la règle | 1 | | garde de routeur retirée de `HaveKeyButton` | 1 | | `ProfileFormModal` ne passe plus `onNavigate={onClose}` | **0** (demande un clic, voir plus bas) | | URL d'achat figée en français (`PurchaseButton`) | 1 | | note Premium absente | 1 | | `onNavigate` non transmis à `HaveKeyButton` | 1 | | échec d'`openUrl` non journalisé (`PurchaseButton`) | 1 | | échec d'`openUrl` non journalisé (`DownloadLatestButton`) | 1 | | URL figée en français (`DownloadLatestButton`) | 1 | Chaque mutation rouge sort avec le code 1. ### CI Run 430 (`check-frontend`) vert sur `170b22d`, log lu : `HEAD is now at 170b22d`, build OK, 65 fichiers, **1266** tests (1257 + 9). `check-rust` ne s'est pas déclenché, à raison : aucun fichier sous `src-tauri/`. ### Reste à vérifier à la main En édition Gratuite, ouvrir le modal de création de profil depuis `ProfileSwitcher` et cliquer « J'ai déjà une clé » : le modal doit se fermer sur la carte Licence. ### Avant toute release Le checkout du site reste en mode test tant que la-compagnie-maximus#141 et maximus-api#62 sont ouvertes : aucun tag ne doit embarquer ce bouton actif avant. La contrainte est consignée dans `STATE.md` au merge.
maximus merged commit 170b22d356 into main 2026-09-28 00:51:07 +00:00
maximus deleted branch issue-346-purchase-cta 2026-09-28 00:51:07 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#362
No description provided.