diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index e6b4701..6af08d3 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -4,6 +4,7 @@ ### 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) ## [0.6.7] - 2026-03-29 diff --git a/CHANGELOG.md b/CHANGELOG.md index 391185b..c0364c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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) ## [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.title")} +

+ +
+
+

+ {t("license.currentEdition")} +

+

+ {editionLabel} + {state.edition !== "free" && ( + + )} +

+
+ {state.info && state.info.expires_at > 0 && ( +
+

+ {t("license.expiresAt")} +

+

{formatExpiry(state.info.expires_at)}

+
+ )} +
+ + {state.status === "error" && state.error && ( +
+ +

{state.error}

+
+ )} + + {!showInput && ( +
+ + {state.edition === "free" && ( + + )} +
+ )} + + {showInput && ( +
+ setKeyInput(e.target.value)} + placeholder={t("license.keyPlaceholder")} + className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]" + autoFocus + /> +
+ + +
+
+ )} +
+ ); +} 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/i18n/locales/en.json b/src/i18n/locales/en.json index e7b4011..8f27463 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -846,5 +846,19 @@ "total": "Total", "darkMode": "Dark mode", "lightMode": "Light mode" + }, + "license": { + "title": "License", + "currentEdition": "Current edition", + "expiresAt": "Expires on", + "enterKey": "Enter a license key", + "keyPlaceholder": "SR-BASE-...", + "activate": "Activate", + "purchase": "Buy Simpl'Result", + "editions": { + "free": "Free", + "base": "Base", + "premium": "Premium" + } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index af3cba4..0f308a0 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -846,5 +846,19 @@ "total": "Total", "darkMode": "Mode sombre", "lightMode": "Mode clair" + }, + "license": { + "title": "Licence", + "currentEdition": "Édition actuelle", + "expiresAt": "Expire le", + "enterKey": "Entrer une clé de licence", + "keyPlaceholder": "SR-BASE-...", + "activate": "Activer", + "purchase": "Acheter Simpl'Résultat", + "editions": { + "free": "Gratuite", + "base": "Base", + "premium": "Premium" + } } } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6e56b16..2d9a476 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -19,6 +19,7 @@ import { Link } from "react-router-dom"; import { APP_NAME } from "../shared/constants"; import { PageHelp } from "../components/shared/PageHelp"; import DataManagementCard from "../components/settings/DataManagementCard"; +import LicenseCard from "../components/settings/LicenseCard"; import LogViewerCard from "../components/settings/LogViewerCard"; export default function SettingsPage() { @@ -72,6 +73,9 @@ export default function SettingsPage() { + {/* License card */} + + {/* About card */}
diff --git a/src/services/licenseService.ts b/src/services/licenseService.ts new file mode 100644 index 0000000..73beee9 --- /dev/null +++ b/src/services/licenseService.ts @@ -0,0 +1,36 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type Edition = "free" | "base" | "premium"; + +export interface LicenseInfo { + edition: Edition; + email: string; + features: string[]; + machine_limit: number; + issued_at: number; + expires_at: number; +} + +export async function validateLicenseKey(key: string): Promise { + return invoke("validate_license_key", { key }); +} + +export async function storeLicense(key: string): Promise { + return invoke("store_license", { key }); +} + +export async function readLicense(): Promise { + return invoke("read_license"); +} + +export async function getEdition(): Promise { + return invoke("get_edition"); +} + +export async function getMachineId(): Promise { + return invoke("get_machine_id"); +} + +export async function checkEntitlement(feature: string): Promise { + return invoke("check_entitlement", { feature }); +}