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
11 changed files with 171 additions and 17 deletions
Showing only changes of commit 170b22d356 - Show all commits

View file

@ -4,13 +4,13 @@
### Ajouté ### Ajouté
- Achat de l'édition Base depuis chaque écran verrouillé : « 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). - 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). - 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é ### 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). - 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 de « Achat en ligne bientôt disponible » (#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). - En édition Gratuite, la carte Mises à jour indique que les mises à jour automatiques sont incluses dans l'édition Base (#346).
### Corrigé ### Corrigé

View file

@ -4,12 +4,12 @@
### Added ### Added
- Buy the Base edition from every locked screen: "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). - 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). - 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 ### Changed
- The Compte Maximus 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 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). - 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). - In the free edition, the Updates card says automatic updates are included in the Base edition (#346).

View file

@ -6,7 +6,7 @@
- Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md) - 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) - 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`). Le reste de la décision est inchangé. > **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 ## Contexte

View file

@ -542,15 +542,16 @@ 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 : 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** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton reste désactivé : Premium n'est pas encore offert en ligne - **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é - **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
### Comment faire ### 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 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 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 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. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire ; pour acheter l'édition Base, cliquez sur « Obtenir Base », qui ouvre la page produit (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 ### Astuces

View file

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

View file

@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import { purchaseUrl } from "../../shared/purchaseUrl"; import { purchaseUrl } from "../../shared/purchaseUrl";
import { logError } from "../../services/logService";
const DEFAULT_LAYOUT = "inline-flex items-center justify-center gap-2 text-sm"; const DEFAULT_LAYOUT = "inline-flex items-center justify-center gap-2 text-sm";
const COLOURS = "text-[var(--primary)] hover:underline"; const COLOURS = "text-[var(--primary)] hover:underline";
@ -15,7 +16,8 @@ interface DownloadLatestButtonProps {
* "Download the latest version", shown in the free edition where automatic updates * "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 * 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 * installers directly (la-compagnie-maximus#153). No update check runs here: the
* free edition makes no network call for updates. * free edition makes no network call for updates. A failure to open the browser
* is logged, like PurchaseButton's.
*/ */
export default function DownloadLatestButton({ export default function DownloadLatestButton({
className = DEFAULT_LAYOUT, className = DEFAULT_LAYOUT,
@ -24,7 +26,11 @@ export default function DownloadLatestButton({
return ( return (
<button <button
type="button" type="button"
onClick={() => void openUrl(purchaseUrl(i18n.language))} onClick={() =>
void openUrl(purchaseUrl(i18n.language)).catch((e) =>
logError("Product page: openUrl failed", e),
)
}
className={`${className} ${COLOURS}`} className={`${className} ${COLOURS}`}
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />

View file

@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { ShoppingCart } from "lucide-react"; import { ShoppingCart } from "lucide-react";
import { purchaseUrl } from "../../shared/purchaseUrl"; 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 DEFAULT_LAYOUT = "w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md";
const COLOURS = const COLOURS =
@ -16,14 +17,19 @@ interface PurchaseButtonProps {
* "Get Base": opens the localized product page in the system browser (issue #346). * "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 * 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 * BrowserRouter. It never calls a modal host's onNavigate: the page opens in the
* browser and the app stays where it was. * 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) { export default function PurchaseButton({ className = DEFAULT_LAYOUT }: PurchaseButtonProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
return ( return (
<button <button
type="button" type="button"
onClick={() => void openUrl(purchaseUrl(i18n.language))} onClick={() =>
void openUrl(purchaseUrl(i18n.language)).catch((e) =>
logError("Product page: openUrl failed", e),
)
}
className={`${className} ${COLOURS}`} className={`${className} ${COLOURS}`}
> >
<ShoppingCart className="h-4 w-4" /> <ShoppingCart className="h-4 w-4" />

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

@ -1113,8 +1113,9 @@
"steps": [ "steps": [
"Look for the lock badge in the sidebar or on the Reports hub tiles: it marks the modules above your edition", "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", "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", "To buy the Base edition, click \"Get Base\": the product page opens in your browser (Premium isn't sold online yet)",
"Modules unlock immediately — no reinstall or restart needed; to buy the Base edition, click \"Get Base\", which opens the product page (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": [ "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", "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",

View file

@ -1113,8 +1113,9 @@
"steps": [ "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", "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", "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", "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)",
"Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire ; pour acheter l'édition Base, cliquez sur « Obtenir Base », qui ouvre la page produit (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": [ "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", "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",

View file

@ -17,6 +17,10 @@ export function upsellCtaFor(tier: Edition): UpsellCta {
case "base": case "base":
return { purchasable: true, noteKey: null }; return { purchasable: true, noteKey: null };
case "premium": 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" }; return { purchasable: false, noteKey: "upsell.premiumNotForSale" };
default: default:
// No feature requires the free edition (see requiredTierFor). // No feature requires the free edition (see requiredTierFor).