Simpl-Resultat/src/components/settings/LicenseCard.tsx
le king fu 170b22d356
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m39s
fix(upsell): apply the review of #362
- 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>
2026-09-27 20:47:04 -04:00

252 lines
9.7 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
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();
const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
useLicenseContext();
const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false);
const hasLicense = edition !== "free";
const {
state: machines,
activate,
deactivate,
toggleList,
showEmptyState,
canDeactivate,
canActivate,
} = useLicenseMachines(hasLicense);
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(purchaseUrl(i18n.language)).catch((e) =>
logError("Product page: openUrl failed", e),
);
};
const formatExpiry = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString();
};
const errorMessage = (err: LicenseError) => t(err.i18nKey, err.params);
const editionLabel = t(`license.editions.${edition}`);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<KeyRound size={18} />
{t("license.title")}
</h2>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("license.currentEdition")}
</p>
<p className="text-base font-medium">
{editionLabel}
{edition !== "free" && (
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
)}
</p>
</div>
{info && info.expires_at > 0 && (
<div className="text-right">
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.expiresAt")}
</p>
<p className="text-sm">{formatExpiry(info.expires_at)}</p>
</div>
)}
</div>
{licenseStatus === "error" && error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{error}</p>
</div>
)}
{!showInput && (
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setShowInput(true)}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
{t("license.enterKey")}
</button>
{edition === "free" && (
<button
type="button"
onClick={handlePurchase}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
>
<ExternalLink size={14} />
{t("license.purchase")}
</button>
)}
</div>
)}
{showInput && (
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
value={keyInput}
onChange={(e) => 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
/>
{validationError && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{validationError}</p>
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={validating || !keyInput.trim()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
>
{validating && <Loader2 size={14} className="animate-spin" />}
{t("license.activate")}
</button>
<button
type="button"
onClick={() => {
setShowInput(false);
setKeyInput("");
}}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
{t("common.cancel")}
</button>
</div>
</form>
)}
{hasLicense && (
<div className="border-t border-[var(--border)] pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Monitor size={16} />
{t("license.machines.title")}
</h3>
<div className="flex items-center gap-2">
{machines.activation && !machines.activation.is_activated && (
<button
type="button"
onClick={() => void activate()}
disabled={!canActivate}
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
>
{machines.activating && <Loader2 size={12} className="animate-spin" />}
{t("license.activate")}
</button>
)}
{machines.activation?.is_activated && (
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
<CheckCircle size={12} />
{t("license.machines.activated")}
</span>
)}
<button
type="button"
onClick={toggleList}
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
>
{machines.listOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
{machines.activationError && (
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<p>{errorMessage(machines.activationError)}</p>
</div>
)}
{machines.listOpen && (
<div className="space-y-2">
{machines.listLoading && machines.machines.length === 0 && (
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<Loader2 size={12} className="animate-spin" />
{t("common.loading")}
</div>
)}
{machines.listError && (
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<p>{errorMessage(machines.listError)}</p>
</div>
)}
{showEmptyState && (
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.machines.noMachines")}
</p>
)}
{machines.machines.map((m) => {
const isThis = machines.activation?.machine_id === m.machine_id;
return (
<div
key={m.machine_id}
className="flex items-center justify-between px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm"
>
<div>
<span className="font-medium">
{m.machine_name || m.machine_id.slice(0, 12)}
</span>
{isThis && (
<span className="ml-2 text-xs text-[var(--positive)]">
({t("license.machines.thisMachine")})
</span>
)}
<p className="text-xs text-[var(--muted-foreground)]">
{new Date(m.activated_at).toLocaleDateString()}
</p>
</div>
<button
type="button"
onClick={() => void deactivate(m.machine_id)}
disabled={!canDeactivate}
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{machines.deactivatingId === m.machine_id && (
<Loader2 size={10} className="animate-spin" />
)}
{t("license.machines.deactivate")}
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}