diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index e6b4701..c5fe501 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -4,6 +4,10 @@ ### Ajouté - CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60) +- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47) + +### Modifié +- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48) ## [0.6.7] - 2026-03-29 diff --git a/CHANGELOG.md b/CHANGELOG.md index 391185b..70b1dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added - CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60) +- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47) + +### Changed +- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48) ## [0.6.7] - 2026-03-29 diff --git a/src/components/settings/LicenseCard.tsx b/src/components/settings/LicenseCard.tsx new file mode 100644 index 0000000..d15a659 --- /dev/null +++ b/src/components/settings/LicenseCard.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink } from "lucide-react"; +import { useLicense } from "../../hooks/useLicense"; + +const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat"; + +export default function LicenseCard() { + const { t } = useTranslation(); + const { state, submitKey } = useLicense(); + const [keyInput, setKeyInput] = useState(""); + const [showInput, setShowInput] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = keyInput.trim(); + if (!trimmed) return; + const result = await submitKey(trimmed); + if (result.ok) { + setKeyInput(""); + setShowInput(false); + } + }; + + const handlePurchase = () => { + void openUrl(PURCHASE_URL); + }; + + const formatExpiry = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString(); + }; + + const editionLabel = t(`license.editions.${state.edition}`); + + return ( +
+ {t("license.currentEdition")} +
+
+ {editionLabel}
+ {state.edition !== "free" && (
+
+ {t("license.expiresAt")} +
+{formatExpiry(state.info.expires_at)}
+{state.error}
++ {t("error.updateNotEntitled")} +
+ )} {updateStatus === "error" && updateError && ({updateError}
)} diff --git a/src/hooks/useLicense.ts b/src/hooks/useLicense.ts new file mode 100644 index 0000000..a43b703 --- /dev/null +++ b/src/hooks/useLicense.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useReducer } from "react"; +import { + Edition, + LicenseInfo, + checkEntitlement as checkEntitlementCmd, + getEdition, + readLicense, + storeLicense, +} from "../services/licenseService"; + +type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error"; + +interface LicenseState { + status: LicenseStatus; + edition: Edition; + info: LicenseInfo | null; + error: string | null; +} + +type LicenseAction = + | { type: "LOAD_START" } + | { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null } + | { type: "VALIDATE_START" } + | { type: "VALIDATE_DONE"; info: LicenseInfo } + | { type: "ERROR"; error: string }; + +const initialState: LicenseState = { + status: "idle", + edition: "free", + info: null, + error: null, +}; + +function reducer(state: LicenseState, action: LicenseAction): LicenseState { + switch (action.type) { + case "LOAD_START": + return { ...state, status: "loading", error: null }; + case "LOAD_DONE": + return { + status: "ready", + edition: action.edition, + info: action.info, + error: null, + }; + case "VALIDATE_START": + return { ...state, status: "validating", error: null }; + case "VALIDATE_DONE": + return { + status: "ready", + edition: action.info.edition, + info: action.info, + error: null, + }; + case "ERROR": + return { ...state, status: "error", error: action.error }; + } +} + +export function useLicense() { + const [state, dispatch] = useReducer(reducer, initialState); + + const refresh = useCallback(async () => { + dispatch({ type: "LOAD_START" }); + try { + const [edition, info] = await Promise.all([getEdition(), readLicense()]); + dispatch({ type: "LOAD_DONE", edition, info }); + } catch (e) { + dispatch({ + type: "ERROR", + error: e instanceof Error ? e.message : String(e), + }); + } + }, []); + + const submitKey = useCallback(async (key: string) => { + dispatch({ type: "VALIDATE_START" }); + try { + const info = await storeLicense(key); + dispatch({ type: "VALIDATE_DONE", info }); + return { ok: true as const, info }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + dispatch({ type: "ERROR", error: message }); + return { ok: false as const, error: message }; + } + }, []); + + const checkEntitlement = useCallback( + (feature: string) => checkEntitlementCmd(feature), + [], + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { state, refresh, submitKey, checkEntitlement }; +} diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index b694786..9bf9f34 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -1,6 +1,7 @@ import { useReducer, useCallback, useRef } from "react"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; +import { invoke } from "@tauri-apps/api/core"; type UpdateStatus = | "idle" @@ -10,6 +11,7 @@ type UpdateStatus = | "downloading" | "readyToInstall" | "installing" + | "notEntitled" | "error"; interface UpdaterState { @@ -29,6 +31,7 @@ type UpdaterAction = | { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null } | { type: "READY_TO_INSTALL" } | { type: "INSTALLING" } + | { type: "NOT_ENTITLED" } | { type: "ERROR"; error: string }; const initialState: UpdaterState = { @@ -56,6 +59,8 @@ function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState { return { ...state, status: "readyToInstall", error: null }; case "INSTALLING": return { ...state, status: "installing", error: null }; + case "NOT_ENTITLED": + return { ...state, status: "notEntitled", error: null }; case "ERROR": return { ...state, status: "error", error: action.error }; } @@ -68,6 +73,16 @@ export function useUpdater() { const checkForUpdate = useCallback(async () => { dispatch({ type: "CHECK_START" }); try { + // Auto-updates are gated behind the entitlements module (Issue #46/#48). + // The check is centralized server-side via `check_entitlement` so the + // tier→feature mapping lives in one place. + const allowed = await invoke{t("settings.updates.notEntitled")}
+