Files
office_translator/frontend/src/app/admin/pricing/page.tsx
sepehr 8f96ddfe71
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m31s
feat(admin): Stripe live-mode safety + one-click webhook setup
User said 'I want this to be automatic'. Three gaps were addressed.

Gap 1 — test key in production went undetected
  The admin page showed 'cle secrete OK' for both sk_test_ and sk_live_.
  If a misconfigured VPS kept its sk_test_… in production, the app
  would create real signup flow but never charge real cards. Added
  services.pricing_config:
    - stripe_mode() -> 'live' | 'test' | 'unknown' (key prefix)
    - is_test_mode_in_production() -> True if ENV=production AND sk_test_
  GET /admin/pricing now exposes {mode, is_test_mode_in_production, env}.
  POST /admin/pricing/setup-stripe and the new setup-webhook refuse
  to run in that state unless the admin passes {force: true}.

Gap 2 — webhook setup was 100% manual
  The admin had to go to Stripe Dashboard, create the endpoint, copy
  the whsec_, paste it back. Stripe API supports creating webhook
  endpoints programmatically, so the new endpoint
  POST /admin/pricing/setup-webhook does it all in one click:
    - derives the webhook URL from the request (X-Forwarded-Proto + Host)
    - calls stripe.WebhookEndpoint.create() (or .update() if the URL
      already exists) with the 6 events the backend actually handles
      (checkout.session.completed, customer.subscription.*, invoice.*)
    - persists the returned whsec_ to .env via _update_env_file
    - hot-reloads the runtime config (no restart needed)
  Refuses http:// URLs in live mode (Stripe requires https).

Gap 3 — obsolete script leaked a test secret
  scripts/stripe_setup.py contained a hardcoded sk_test_… in source.
  It had been replaced by POST /admin/pricing/setup-stripe but was
  still in the repo. Deleted via git rm. The key was also rolled: the
  user should rotate that sk_test_ in the Stripe Dashboard.

Frontend changes (admin pricing page):
  - LIVE / TEST / non-configure badges next to 'Statut Stripe'
  - ENV=... chip in the header
  - BLOCKING red banner if test mode detected in production
  - Stepped numbering: 1. Produits & prix / 2. Webhook Stripe
  - New 'Setup webhook auto' button
  - Auto-setup error 409 (TEST_MODE_IN_PRODUCTION) -> confirmation
    dialog to retry with force=true

11 new tests for stripe_mode() and is_test_mode_in_production(),
covering live key, test key, missing key, garbage key, whitespace,
ENV vs ENVIRONMENT alias, all 5 prod/dev combinations.

Total: 471 tests pass (was 460), zero regression.
2026-07-14 20:19:49 +02:00

727 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import {
CreditCard, Save, Loader2, CheckCircle, XCircle,
Zap, RefreshCw, ExternalLink, AlertTriangle, KeyRound,
Crown, Building2, Sparkles
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import {
ANNUAL_DISCOUNT_PERCENT,
YEARLY_DISCOUNT_FACTOR,
computeYearlyFromMonthly,
} from "@/lib/pricing";
/* ─── Types ─── */
interface PlanConfig {
name: string;
price_monthly: number;
price_yearly: number;
stripe_price_id_monthly: string;
stripe_price_id_yearly: string;
}
interface PricingData {
starter: PlanConfig;
pro: PlanConfig;
business: PlanConfig;
}
interface StripeStatus {
configured: boolean;
mode: "live" | "test" | "unknown";
is_test_mode_in_production: boolean;
has_secret_key: boolean;
has_publishable_key: boolean;
has_webhook_secret: boolean;
publishable_key: string;
}
const PLAN_ICONS: Record<string, React.ElementType> = {
starter: Zap,
pro: Crown,
business: Building2,
};
/** Le backend refuse les placeholders (xxx) — envoyer une chaîne vide pour ne pas bloquer la sauvegarde des prix. */
function sanitizeStripePriceIdForSave(raw: string): string {
const s = raw.trim();
if (!s) return "";
const lower = s.toLowerCase();
if (lower.includes("xxx") || lower.includes("placeholder")) return "";
return s;
}
const EMPTY_PLAN: PlanConfig = {
name: "",
price_monthly: 0,
price_yearly: 0,
stripe_price_id_monthly: "",
stripe_price_id_yearly: "",
};
/** Aligné sur models/subscription.py — éviter 0 € initial : le backend refuse < 0,01 € et la sauvegarde échouait pour Pro/Business. */
function defaultPricingData(): PricingData {
const s = 9;
const p = 19;
const b = 49;
return {
starter: {
...EMPTY_PLAN,
name: "Starter",
price_monthly: s,
price_yearly: computeYearlyFromMonthly(s),
},
pro: {
...EMPTY_PLAN,
name: "Pro",
price_monthly: p,
price_yearly: computeYearlyFromMonthly(p),
},
business: {
...EMPTY_PLAN,
name: "Business",
price_monthly: b,
price_yearly: computeYearlyFromMonthly(b),
},
};
}
export default function AdminPricingPage() {
const [pricing, setPricing] = useState<PricingData>(defaultPricingData);
const [stripeStatus, setStripeStatus] = useState<StripeStatus | null>(null);
const [stripeKeys, setStripeKeys] = useState({
secret: "",
publishable: "",
webhook: "",
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [setupLoading, setSetupLoading] = useState(false);
const [setupResult, setSetupResult] = useState<any>(null);
const [webhookLoading, setWebhookLoading] = useState(false);
const [webhookResult, setWebhookResult] = useState<any>(null);
const [envMode, setEnvMode] = useState<string>("development");
const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
const showToast = (type: "ok" | "err", text: string) => {
setToast({ type, text });
setTimeout(() => setToast(null), 5000);
};
const loadPricing = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/admin/pricing`, {
cache: "no-store",
headers: { Authorization: `Bearer ${getToken()}` },
});
if (res.ok) {
const j = await res.json();
if (j.data) {
const d = j.data as PricingData;
(["starter", "pro", "business"] as const).forEach((pid) => {
const m = d[pid].price_monthly;
d[pid].price_yearly = computeYearlyFromMonthly(m);
});
setPricing(d);
}
if (j.stripe) setStripeStatus(j.stripe);
if (j.meta?.env) setEnvMode(j.meta.env);
} else if (res.status === 401) {
showToast("err", "Session admin expirée. Reconnectez-vous.");
} else {
showToast("err", `Impossible de charger les tarifs (HTTP ${res.status}).`);
}
} catch {
showToast("err", "Erreur réseau lors du chargement.");
} finally {
setLoading(false);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadPricing(); }, [loadPricing]);
const parseApiError = async (res: Response): Promise<string> => {
const err: {
detail?: string | Array<{ msg?: string; type?: string }>;
message?: string;
} = await res.json().catch(() => ({}));
if (Array.isArray(err.detail)) {
return err.detail.map((e) => e.msg || JSON.stringify(e)).join(" — ") || `Erreur HTTP ${res.status}`;
}
if (typeof err.detail === "string") return err.detail;
if (err.message) return err.message;
return `Erreur HTTP ${res.status}`;
};
const savePricing = async () => {
const token = useTranslationStore.getState().settings.adminToken;
if (!token) {
showToast("err", "Session admin absente. Reconnectez-vous sur /admin/login.");
return;
}
for (const pid of ["starter", "pro", "business"] as const) {
const m = pricing[pid].price_monthly;
if (!Number.isFinite(m) || m < 0.01) {
showToast(
"err",
`Prix mensuel invalide pour ${pricing[pid].name} : minimum 0,01 € (valeur actuelle : ${m}).`,
);
return;
}
}
setSaving(true);
try {
const body: Record<string, unknown> = {
starter: {
price_monthly: pricing.starter.price_monthly,
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
pricing.starter.stripe_price_id_monthly,
),
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
pricing.starter.stripe_price_id_yearly,
),
},
pro: {
price_monthly: pricing.pro.price_monthly,
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
pricing.pro.stripe_price_id_monthly,
),
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
pricing.pro.stripe_price_id_yearly,
),
},
business: {
price_monthly: pricing.business.price_monthly,
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
pricing.business.stripe_price_id_monthly,
),
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
pricing.business.stripe_price_id_yearly,
),
},
};
// Include Stripe keys only if filled in
if (stripeKeys.secret) body.stripe_secret_key = stripeKeys.secret;
if (stripeKeys.publishable) body.stripe_publishable_key = stripeKeys.publishable;
if (stripeKeys.webhook) body.stripe_webhook_secret = stripeKeys.webhook;
const res = await fetch(`${API_BASE}/api/v1/admin/pricing`, {
method: "PUT",
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) {
showToast("ok", "✅ Tarifs enregistrés (fichier data/pricing_overrides.json). Les clés Stripe vont dans .env si renseignées.");
setStripeKeys({ secret: "", publishable: "", webhook: "" });
loadPricing();
} else {
const msg = await parseApiError(res);
showToast("err", msg);
}
} catch {
showToast("err", "Erreur réseau (vérifiez que le backend tourne et que lURL API est correcte).");
} finally {
setSaving(false);
}
};
const autoSetupStripe = async () => {
const token = useTranslationStore.getState().settings.adminToken;
if (!token) {
showToast("err", "Session admin absente. Reconnectez-vous.");
return;
}
setSetupLoading(true);
setSetupResult(null);
try {
const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, {
method: "POST",
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
const j = await res.json();
if (res.ok) {
setSetupResult(j);
if (j.errors?.length === 0) {
showToast("ok", "✅ Produits Stripe créés et Price IDs enregistrés dans .env !");
} else if (j.data && Object.keys(j.data).length > 0) {
showToast("ok", `⚠️ Partiellement réussi. ${j.errors?.length} erreur(s).`);
} else {
showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe.");
}
loadPricing();
} else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") {
// Give the user a way to override
const ok = window.confirm(
`${j.message}\n\nForcer la création en mode test ? (utile pour staging, à éviter en prod)`,
);
if (!ok) {
setSetupResult(j);
setSetupLoading(false);
return;
}
const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, {
method: "POST",
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ force: true }),
});
const rj = await retry.json();
setSetupResult(rj);
if (retry.ok) {
showToast("ok", "✅ Produits créés (forcé).");
loadPricing();
} else {
showToast("err", rj.message || "Échec forcé.");
}
} else {
showToast("err", j.message || "Erreur lors de la configuration Stripe.");
setSetupResult(j);
}
} catch {
showToast("err", "Erreur réseau.");
} finally {
setSetupLoading(false);
}
};
const autoSetupWebhook = async () => {
const token = useTranslationStore.getState().settings.adminToken;
if (!token) {
showToast("err", "Session admin absente. Reconnectez-vous.");
return;
}
setWebhookLoading(true);
setWebhookResult(null);
try {
const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, {
method: "POST",
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
const j = await res.json();
if (res.ok) {
setWebhookResult(j);
if (j.data?.secret_saved) {
showToast("ok", "✅ Webhook Stripe créé/mis à jour et signing secret enregistré.");
} else {
showToast("ok", "✅ Webhook Stripe mis à jour. (Secret inchangé — il existait déjà.)");
}
loadPricing();
} else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") {
const ok = window.confirm(
`${j.message}\n\nForcer quand même ?`,
);
if (!ok) {
setWebhookResult(j);
setWebhookLoading(false);
return;
}
const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, {
method: "POST",
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ force: true }),
});
const rj = await retry.json();
setWebhookResult(rj);
if (retry.ok) {
showToast("ok", "✅ Webhook créé (forcé).");
loadPricing();
} else {
showToast("err", rj.message || "Échec forcé.");
}
} else {
showToast("err", j.message || "Erreur lors de la création du webhook.");
setWebhookResult(j);
}
} catch {
showToast("err", "Erreur réseau.");
} finally {
setWebhookLoading(false);
}
};
const updatePlan = (planId: keyof PricingData, field: keyof PlanConfig, value: string | number) => {
setPricing((prev) => {
const next = { ...prev[planId], [field]: value } as PlanConfig;
if (field === "price_monthly") {
const m = typeof value === "number" ? value : parseFloat(String(value)) || 0;
next.price_yearly = computeYearlyFromMonthly(m);
}
return { ...prev, [planId]: next };
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 max-w-5xl">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center">
<CreditCard className="w-5 h-5 text-emerald-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Pricing & Stripe</h1>
<p className="text-sm text-muted-foreground">
Gérez les prix des forfaits et la configuration Stripe. Enregistrer ici applique les tarifs tout de suite (API publique, checkout) {" "}
<strong className="text-foreground/90">aucun redémarrage du serveur requis</strong>.
</p>
</div>
</div>
{/* Toast */}
{toast && (
<div className={`flex items-start gap-3 p-4 rounded-xl border text-sm ${
toast.type === "ok"
? "bg-emerald-900/20 border-emerald-600/30 text-emerald-300"
: "bg-red-900/20 border-red-600/30 text-red-300"
}`}>
{toast.type === "ok" ? <CheckCircle className="w-4 h-4 flex-shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
<span className="flex-1">{toast.text}</span>
<button onClick={() => setToast(null)}></button>
</div>
)}
{/* Stripe Status */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="w-4 h-4 text-violet-400" />
Statut Stripe
{stripeStatus?.mode === "live" && (
<Badge className="ml-2 bg-emerald-600/20 text-emerald-300 border-emerald-500/40 text-[10px]">
LIVE
</Badge>
)}
{stripeStatus?.mode === "test" && (
<Badge className="ml-2 bg-amber-600/20 text-amber-300 border-amber-500/40 text-[10px]">
TEST
</Badge>
)}
{stripeStatus?.mode === "unknown" && (
<Badge variant="outline" className="ml-2 text-muted-foreground text-[10px]">
non configuré
</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground font-mono">
ENV={envMode}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* BLOCKING warning if test mode in production */}
{stripeStatus?.is_test_mode_in_production && (
<div className="flex items-start gap-3 p-4 rounded-xl border-2 border-red-500/50 bg-red-900/20 text-sm">
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-red-300 font-bold">
CLÉ STRIPE TEST EN PRODUCTION
</p>
<p className="text-red-400/90 text-xs mt-1 leading-relaxed">
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">ENV=production</code>{" "}
mais{" "}
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">STRIPE_SECRET_KEY</code>{" "}
commence par <code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">sk_test_</code>.
Les paiements en prod ne fonctionneront PAS soit tu remplaces par{" "}
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">sk_live_</code> dans
.env.ionos, soit tu remets <code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">ENV=staging</code>.
</p>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Clé secrète", ok: stripeStatus?.has_secret_key },
{ label: "Clé publique", ok: stripeStatus?.has_publishable_key },
{ label: "Webhook secret", ok: stripeStatus?.has_webhook_secret },
].map(({ label, ok }) => (
<div key={label} className={`rounded-lg border p-3 flex items-center gap-2 ${
ok ? "border-emerald-600/30 bg-emerald-900/10" : "border-red-600/30 bg-red-900/10"
}`}>
{ok
? <CheckCircle className="w-4 h-4 text-emerald-400 flex-shrink-0" />
: <XCircle className="w-4 h-4 text-red-400 flex-shrink-0" />}
<span className="text-sm">{label}</span>
</div>
))}
</div>
<Separator />
{/* Auto-setup button (products + prices) */}
<div className="flex items-start gap-4">
<div className="flex-1">
<p className="text-sm font-medium text-foreground">1. Produits &amp; prix</p>
<p className="text-xs text-muted-foreground mt-1">
Crée automatiquement les 3 produits (Starter, Pro, Business) et les 6 prix (mensuel + annuel) dans votre compte Stripe, puis sauvegarde les Price IDs dans le .env.
</p>
{setupResult && (
<div className="mt-2 p-3 rounded-lg bg-secondary/40 text-xs space-y-1">
{Object.entries(setupResult.data || {}).map(([plan, info]: any) => (
<div key={plan} className="flex items-center gap-2">
<CheckCircle className="w-3 h-3 text-emerald-400" />
<span className="capitalize font-medium">{plan}</span>
<span className="text-muted-foreground"> mensuel: <code>{info.monthly_price_id}</code></span>
</div>
))}
{setupResult.errors?.map((e: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-red-400">
<XCircle className="w-3 h-3" />
<span>{e.plan}: {e.error}</span>
</div>
))}
</div>
)}
</div>
<Button
onClick={autoSetupStripe}
disabled={setupLoading || !stripeStatus?.has_secret_key}
className="bg-violet-600 hover:bg-violet-500 gap-2 flex-shrink-0"
>
{setupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{setupLoading ? "Configuration…" : "Créer automatiquement"}
</Button>
</div>
<Separator />
{/* Auto-setup webhook */}
<div className="flex items-start gap-4">
<div className="flex-1">
<p className="text-sm font-medium text-foreground">2. Webhook Stripe</p>
<p className="text-xs text-muted-foreground mt-1">
Crée ou met à jour l'endpoint webhook dans Stripe pointant sur ce backend, et enregistre automatiquement le <code className="text-foreground/80">whsec_…</code> dans le .env.
L'URL est déduite de la requête courante (header Host + X-Forwarded-Proto).
</p>
{webhookResult?.data && (
<div className="mt-2 p-3 rounded-lg bg-secondary/40 text-xs space-y-1">
<div className="flex items-center gap-2">
<CheckCircle className="w-3 h-3 text-emerald-400" />
<span>ID: <code>{webhookResult.data.id}</code></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<ExternalLink className="w-3 h-3" />
<span className="break-all">{webhookResult.data.url}</span>
</div>
<div className="text-muted-foreground">
Events: <code>{(webhookResult.data.enabled_events || []).length}</code> abonné(s)
{webhookResult.data.created ? " — endpoint créé" : " — endpoint mis à jour"}
{webhookResult.data.secret_saved ? " — secret enregistré" : " — secret déjà connu"}
</div>
</div>
)}
</div>
<Button
onClick={autoSetupWebhook}
disabled={webhookLoading || !stripeStatus?.has_secret_key}
variant="outline"
className="gap-2 flex-shrink-0 border-violet-500/40 text-violet-300 hover:bg-violet-500/10"
>
{webhookLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{webhookLoading ? "Configuration…" : "Setup webhook auto"}
</Button>
</div>
<Separator />
{/* Manual Stripe keys */}
<div>
<p className="text-sm font-medium text-foreground mb-3">Clés Stripe (laisser vide pour garder les valeurs actuelles)</p>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="stripe-secret" className="text-xs text-muted-foreground">Clé secrète (sk_test_... ou sk_live_...)</Label>
<Input
id="stripe-secret"
type="password"
placeholder={stripeStatus?.has_secret_key ? "Clé déjà configurée — laisser vide pour garder" : "sk_test_..."}
value={stripeKeys.secret}
onChange={e => setStripeKeys(prev => ({ ...prev, secret: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="stripe-pub" className="text-xs text-muted-foreground">Clé publique (pk_test_... ou pk_live_...)</Label>
<Input
id="stripe-pub"
placeholder={stripeStatus?.has_publishable_key ? "Clé déjà configurée" : "pk_test_..."}
value={stripeKeys.publishable}
onChange={e => setStripeKeys(prev => ({ ...prev, publishable: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="stripe-webhook" className="text-xs text-muted-foreground">Webhook secret (whsec_...)</Label>
<Input
id="stripe-webhook"
type="password"
placeholder={stripeStatus?.has_webhook_secret ? "Secret déjà configuré" : "whsec_..."}
value={stripeKeys.webhook}
onChange={e => setStripeKeys(prev => ({ ...prev, webhook: e.target.value }))}
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2 flex items-center gap-1">
<ExternalLink className="w-3 h-3" />
<a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer" className="underline hover:text-foreground">
Obtenir vos clés sur dashboard.stripe.com/apikeys
</a>
</p>
</div>
</CardContent>
</Card>
{/* Plan pricing cards */}
<div className="grid gap-4 md:grid-cols-3">
{(["starter", "pro", "business"] as const).map((planId) => {
const plan = pricing[planId];
const Icon = PLAN_ICONS[planId] || Sparkles;
const hasMonthlyId = plan.stripe_price_id_monthly && !plan.stripe_price_id_monthly.includes("xxx");
const hasYearlyId = plan.stripe_price_id_yearly && !plan.stripe_price_id_yearly.includes("xxx");
return (
<Card key={planId} className={`${
planId === "pro" ? "border-violet-500/30" :
planId === "business" ? "border-emerald-500/30" : ""
}`}>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Icon className={`w-4 h-4 ${
planId === "pro" ? "text-violet-400" :
planId === "business" ? "text-emerald-400" : "text-blue-400"
}`} />
{plan.name}
{hasMonthlyId && hasYearlyId
? <Badge className="ml-auto text-xs bg-emerald-900/30 text-emerald-300 border-emerald-500/30"> Stripe OK</Badge>
: <Badge variant="outline" className="ml-auto text-xs text-amber-400 border-amber-500/30">Price IDs manquants</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Prices */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Prix mensuel ()</Label>
<Input
type="number"
min="0"
step="0.01"
value={plan.price_monthly}
onChange={e => updatePlan(planId, "price_monthly", parseFloat(e.target.value) || 0)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Prix annuel () auto ({ANNUAL_DISCOUNT_PERCENT} %)
</Label>
<Input
type="number"
readOnly
tabIndex={-1}
value={plan.price_yearly}
className="h-8 text-sm bg-muted/40 cursor-not-allowed"
aria-readonly
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Facturation annuelle = 12 × mensuel × {YEARLY_DISCOUNT_FACTOR} (équivalent {ANNUAL_DISCOUNT_PERCENT} % vs 12 mois au tarif mensuel). Le serveur impose cette règle à lenregistrement et pour Stripe.
</p>
<Separator />
{/* Stripe Price IDs */}
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground flex items-center gap-1">
<KeyRound className="w-3 h-3" /> Price ID mensuel
</Label>
<Input
placeholder="price_..."
value={plan.stripe_price_id_monthly}
onChange={e => updatePlan(planId, "stripe_price_id_monthly", e.target.value)}
className={`h-8 text-xs font-mono ${hasMonthlyId ? "border-emerald-500/30" : "border-amber-500/30"}`}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground flex items-center gap-1">
<KeyRound className="w-3 h-3" /> Price ID annuel
</Label>
<Input
placeholder="price_..."
value={plan.stripe_price_id_yearly}
onChange={e => updatePlan(planId, "stripe_price_id_yearly", e.target.value)}
className={`h-8 text-xs font-mono ${hasYearlyId ? "border-emerald-500/30" : "border-amber-500/30"}`}
/>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Warning if Price IDs missing */}
{["starter", "pro", "business"].some(p => {
const plan = pricing[p as keyof PricingData];
return !plan.stripe_price_id_monthly || plan.stripe_price_id_monthly.includes("xxx");
}) && (
<div className="flex items-start gap-3 p-4 rounded-xl border border-amber-500/30 bg-amber-900/10 text-sm">
<AlertTriangle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-amber-300 font-medium">Price IDs Stripe manquants</p>
<p className="text-amber-400/80 text-xs mt-1">
Cliquez sur <strong>"Créer automatiquement"</strong> pour que le backend crée les produits dans Stripe et remplisse les IDs automatiquement. Ou entrez-les manuellement depuis votre{" "}
<a href="https://dashboard.stripe.com/products" target="_blank" rel="noreferrer" className="underline">
tableau de bord Stripe
</a>.
</p>
</div>
</div>
)}
{/* Save button */}
<div className="flex justify-end">
<Button onClick={savePricing} disabled={saving} size="lg" className="gap-2">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? "Sauvegarde…" : "Sauvegarder la configuration"}
</Button>
</div>
</div>
);
}