diff --git a/index.html b/index.html
index 76a954d..98a355a 100644
--- a/index.html
+++ b/index.html
@@ -4,8 +4,8 @@
-
@@ -13,7 +13,7 @@ export default function NewServiceCard({ onClick }) {
Demander une accréditation
- Déployer un nouveau serveur Web, VPS ou Cloud.
+ Déployer un nouveau serveur Web, VPS, DB ou Cloud.
);
diff --git a/src/components/dashboard/WebServiceSubscriptionManager.jsx b/src/components/dashboard/WebServiceSubscriptionManager.jsx
new file mode 100644
index 0000000..19f1092
--- /dev/null
+++ b/src/components/dashboard/WebServiceSubscriptionManager.jsx
@@ -0,0 +1,556 @@
+import { useState, useEffect } from 'react';
+import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react';
+import { getProductList, getServiceDetails, cancelOrder } from '../../services/billing_api';
+const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
+
+export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) {
+ const [loading, setLoading] = useState(false);
+ const [cancelLoading, setCancelLoading] = useState(false);
+ const [catalog, setCatalog] = useState([]);
+ const [selectedPlan, setSelectedPlan] = useState(null);
+ const [isCatalogLoading, setIsCatalogLoading] = useState(true);
+
+ const [serviceDetails, setServiceDetails] = useState(null);
+ const [isDetailsLoading, setIsDetailsLoading] = useState(true);
+
+ const [billingPeriod, setBillingPeriod] = useState(order.period || '1M');
+
+ // 🎯 NOUVEAU : État pour la modale de résiliation
+ const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
+
+ // 1. Récupération des détails techniques (Domaine/IP)
+ useEffect(() => {
+ const fetchDetails = async () => {
+ setIsDetailsLoading(true);
+ try {
+ const data = await getServiceDetails(order.id);
+ setServiceDetails(data);
+ } catch (err) {
+ console.error("Erreur détails service:", err);
+ } finally {
+ setIsDetailsLoading(false);
+ }
+ };
+ fetchDetails();
+ }, [order.id]);
+
+ const rawOrderTitle = order.title || "";
+ const titleParts = rawOrderTitle.split(' pour ');
+ const currentPlanName = titleParts[0].trim();
+ const currentPlanId = order.product_id;
+ const currentBillingPeriod = order.period;
+
+ // 2. Extraction robuste du domaine
+ let extractedDomain = "Aucun domaine lié";
+ if (serviceDetails?.config?.hostname || serviceDetails?.config?.sld && serviceDetails?.config?.tld) {
+ extractedDomain = serviceDetails.config.hostname || serviceDetails.config.sld+serviceDetails.config.tld;
+ }
+
+ const renderMarkdownFeatures = (text) => {
+ if (!text) return
Aucune spécification disponible. ;
+ return text.split('\n').map((line, index) => {
+ const cleanLine = line.trim();
+ if (cleanLine.startsWith('* ') || cleanLine.startsWith('- ')) {
+ return (
+
+
+
+
+
{parseBold(cleanLine.substring(2))}
+
+ );
+ }
+ return
{parseBold(cleanLine)}
;
+ });
+ };
+
+ const parseBold = (text) => text.split('**').map((part, i) => i % 2 === 1 ?
{part} : part);
+
+ const getProductCardMetrics = (pricing, period) => {
+ if (!pricing || !pricing.recurrent) {
+ return { displayPrice: "0.00", billingPrice: "0.00", oldDisplayPrice: "0.00", saving: 0, isAvailable: false };
+ }
+
+ const recurrent = pricing.recurrent;
+ const priceW = recurrent['1W']?.price ? parseFloat(recurrent['1W'].price) : 0;
+ const priceM = recurrent['1M']?.price ? parseFloat(recurrent['1M'].price) : 0;
+ const priceY = recurrent['1Y']?.price ? parseFloat(recurrent['1Y'].price) : 0;
+
+ let billingPrice = 0, oldBillingPrice = 0, savingPercent = 0, isAvailable = true;
+ let displayPrice = 0, oldDisplayPrice = 0;
+
+ if (period === '1W') {
+ billingPrice = priceW; oldBillingPrice = priceW;
+ displayPrice = priceW * 4.333; oldDisplayPrice = displayPrice;
+ if (!priceW) isAvailable = false;
+ } else if (period === '1M') {
+ billingPrice = priceM; oldBillingPrice = priceW > 0 ? priceW * 4.333 : priceM;
+ displayPrice = priceM; oldDisplayPrice = oldBillingPrice;
+ if (!priceM) isAvailable = false;
+ } else if (period === '1Y') {
+ billingPrice = priceY; oldBillingPrice = priceW > 0 ? priceW * 52 : (priceM > 0 ? priceM * 12 : priceY);
+ displayPrice = priceY / 12; oldDisplayPrice = oldBillingPrice / 12;
+ if (!priceY) isAvailable = false;
+ }
+
+ savingPercent = (oldBillingPrice > billingPrice && billingPrice > 0) ? Math.round(((oldBillingPrice - billingPrice) / oldBillingPrice) * 100) : 0;
+
+ return { displayPrice: displayPrice.toFixed(2), billingPrice: billingPrice.toFixed(2), oldDisplayPrice: oldDisplayPrice.toFixed(2), saving: savingPercent, isAvailable };
+ };
+
+ const getPlanBadge = (index) => {
+ if (index === 0) return { label: "ESSENTIEL", color: "text-gray-400 bg-gray-900 border-gray-700" };
+ if (index === 1) return { label: "PLUS POPULAIRE", color: "text-cyan-300 bg-cyan-950 border-cyan-800 shadow-[0_0_10px_rgba(6,182,212,0.3)]" };
+ return { label: "PRO", color: "text-amber-400 bg-amber-950 border-amber-800 shadow-[0_0_10px_rgba(245,158,11,0.2)]" };
+ };
+
+ useEffect(() => {
+ const fetchCatalog = async () => {
+ setIsCatalogLoading(true);
+ try {
+ const data = await getProductList();
+ const rawProducts = data.list || data.catalog || (Array.isArray(data) ? data : []);
+ const webProducts = rawProducts.filter(p => p.product_category_id === 1);
+
+ if (webProducts.length > 0) {
+ setCatalog(webProducts);
+ const current = webProducts.find(p => currentPlanName.toLowerCase() === p.title.toLowerCase());
+ if (current) setSelectedPlan(current);
+ } else {
+ console.warn("Aucun produit de type 'Web Service' n'a été trouvé.");
+ setCatalog([]);
+ }
+ } catch (err) {
+ console.error("Erreur API Catalogue:", err);
+ } finally {
+ setIsCatalogLoading(false);
+ }
+ };
+ fetchCatalog();
+ }, [currentPlanName]);
+
+ const isNoChange = selectedPlan && (currentPlanId === selectedPlan.id) && (currentBillingPeriod === billingPeriod);
+
+ // ==========================================
+ // 🧠 LOGIQUE UPGRADE / DOWNGRADE & PRORATA
+ // ==========================================
+ let migrationLabel = "Valider la modification";
+ let buttonColor = "bg-cyan-500 hover:bg-cyan-400 shadow-[0_0_20px_rgba(6,182,212,0.2)]";
+ let ActionIcon = null;
+ let finalInvoicePrice = 0;
+ let isProrataApplied = false;
+ let isRefund = false;
+
+ if (selectedPlan && !isNoChange) {
+ const currentPrice = parseFloat(order.price) || 0;
+ let currentMonthly = currentPrice;
+ if (currentBillingPeriod === '1W') currentMonthly = currentPrice * 4.333;
+ if (currentBillingPeriod === '1M') currentMonthly = currentPrice;
+ if (currentBillingPeriod === '1Y') currentMonthly = currentPrice / 12;
+
+ const selectedMetrics = getProductCardMetrics(selectedPlan.pricing, billingPeriod);
+ const selectedMonthly = parseFloat(selectedMetrics.displayPrice);
+
+ const p1 = parseFloat(order.price) || 0;
+ const p2 = parseFloat(selectedMetrics.billingPrice) || 0;
+
+ let m = 30;
+ if (currentBillingPeriod === '1Y') m = 365;
+ if (currentBillingPeriod === '1M') m = 30;
+ if (currentBillingPeriod === '1W') m = 7;
+
+ const expiresAt = new Date(order.expires_at);
+ const now = new Date();
+ let remainingDays = 0;
+
+ if (!isNaN(expiresAt)) {
+ remainingDays = Math.max(0, Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)));
+ }
+
+ if (remainingDays > 0 && remainingDays <= m) {
+ const n = m - remainingDays; // Jours écoulés
+ finalInvoicePrice = p2 - p1 - (n * (p1 - p2) / m);
+ isProrataApplied = true;
+ } else {
+ finalInvoicePrice = p2;
+ }
+
+ isRefund = finalInvoicePrice < -0.01;
+
+ const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
+ const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0;
+ const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0;
+
+ const isSamePlan = currentPlanId === selectedPlan.id;
+ const isSamePeriod = currentBillingPeriod === billingPeriod;
+
+ if (isSamePlan && !isSamePeriod) {
+ migrationLabel = "Changer de cycle";
+ buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]";
+ ActionIcon =
;
+ } else if (!isSamePlan) {
+ if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) {
+ migrationLabel = `Valider l'Upgrade`;
+ buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]";
+ ActionIcon =
;
+ } else {
+ migrationLabel = "Valider le Downgrade";
+ buttonColor = "bg-amber-500 hover:bg-amber-400 text-black shadow-[0_0_20px_rgba(245,158,11,0.2)]";
+ ActionIcon =
;
+ }
+ }
+ }
+
+ const handleMigration = async () => {
+ setLoading(true);
+ let actionType = 'upgrade';
+ const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
+ const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0);
+ const baseSelected = parseFloat(selectedPlan?.pricing?.recurrent?.['1M']?.price || 0);
+
+ if (selectedPlan.id === currentPlanId) {
+ actionType = 'cycle';
+ } else if (baseSelected < baseCurrent) {
+ actionType = 'downgrade';
+ }
+
+ try {
+ const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ action: 'migrate',
+ order_id: order.id,
+ target_plan_id: selectedPlan.id,
+ new_period: billingPeriod,
+ new_price: finalInvoicePrice.toFixed(2),
+ action_type: actionType
+ })
+ });
+ const data = await resp.json();
+ if (data.status === 'success') {
+ onAlert?.("Facture générée", "Redirection...", "success");
+ onClose?.();
+ setTimeout(() => window.location.href = '/facturation', 2000);
+ } else {
+ onAlert?.("Erreur", data.error, "error");
+ }
+ } catch (err) {
+ onAlert?.("Erreur", "Problème réseau", "error");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // ==========================================
+ // 🛡️ NOUVELLE LOGIQUE DE RÉSILIATION (US 2.1 / 2.2)
+ // ==========================================
+ const calculateRefundEligibility = () => {
+ if (!order || !order.created_at) return false;
+ const orderDate = new Date(order.created_at);
+ const now = new Date();
+ const diffDays = Math.ceil(Math.abs(now - orderDate) / (1000 * 60 * 60 * 24));
+ return diffDays <= 14;
+ };
+
+ const isEligibleForRefund = calculateRefundEligibility();
+
+ const handleConfirmCancel = async () => {
+ setCancelLoading(true);
+ try {
+ const data = await cancelOrder(order.id);
+
+ // Succès normal
+ if (data.status === 'success') {
+ if (onAlert) onAlert("Résilié", data.message, "success");
+ setIsCancelModalOpen(false);
+ if (onRefresh) onRefresh();
+ if (onClose) onClose();
+ } else {
+ if (onAlert) onAlert("Erreur", data.error, "error");
+ }
+ } catch (err) {
+ console.error("Erreur API Annulation:", err);
+
+ // 🛡️ LE FILET DE SÉCURITÉ : Si on détecte l'erreur HTML 502 (Unexpected token)
+ // Cela signifie que le serveur a bien redémarré HestiaCP et coupé la connexion.
+ if (err.message && err.message.includes('Unexpected token')) {
+ if (onAlert) onAlert("Succès", "L'abonnement a été annulé et les services mis à jour.", "success");
+ setIsCancelModalOpen(false);
+ // On met un petit délai avant de rafraîchir, le temps qu'HestiaCP finisse de recharger
+ if (onRefresh) setTimeout(() => onRefresh(), 2000);
+ if (onClose) setTimeout(() => onClose(), 2000);
+ } else {
+ if (onAlert) onAlert("Erreur", "Problème réseau lors de la résiliation.", "error");
+ }
+ } finally {
+ setCancelLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
Abonnement Actuel
+
{currentPlanName}
+
+
+
+
+
+
Espace Domaine
+
{extractedDomain}
+
+
+
+
+
+
+
+
+
+
+
Prochain Renouvellement
+
+ {order.expires_at ? order.expires_at : "Date non définie"}
+ |
+ {order.price || '0.00'} {order.currency || '€'}
+ / {order.period === '1W' ? 'semaine' : order.period === '1Y' ? 'an' : 'mois'}
+
+
+
+
+ Voir les factures
+
+
+
+
+
+ Options de renouvellement
+
+
+ {[
+ { id: '1W', label: 'Semaine' },
+ { id: '1M', label: 'Mois' },
+ { id: '1Y', label: 'Année' }
+ ].map((p) => (
+ setBillingPeriod(p.id)}
+ disabled={loading || cancelLoading}
+ className={`px-5 py-2 rounded-lg text-xs font-black tracking-wide transition-all ${billingPeriod === p.id ? 'bg-cyan-500 text-black shadow-lg shadow-cyan-500/20' : 'text-gray-400 hover:text-white hover:bg-gray-900'} disabled:opacity-50`}
+ >
+ {p.label}
+
+ ))}
+
+
+
+
+
+ {isCatalogLoading ? (
+
+
+
Synchronisation des tarifs...
+
+ ) : (
+
+ {catalog.map((plan, index) => {
+ const metrics = getProductCardMetrics(plan.pricing, billingPeriod);
+ const isSelected = selectedPlan?.id === plan.id;
+ const isCurrentActive = currentPlanId === plan.id && currentBillingPeriod === billingPeriod;
+ const topBadge = getPlanBadge(index);
+
+ return (
+
!loading && !cancelLoading && metrics.isAvailable && setSelectedPlan(plan)}
+ className={`relative h-full p-6 rounded-2xl border transition-all duration-300 flex flex-col justify-between bg-[#0d1527] mt-3
+ ${!metrics.isAvailable || loading || cancelLoading ? 'opacity-50 grayscale cursor-not-allowed border-gray-900' : 'cursor-pointer'}
+ ${isSelected && metrics.isAvailable ? 'border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,0.15)] scale-[1.02] z-10' : 'border-gray-800/80 hover:border-gray-700'}
+ ${isCurrentActive ? 'ring-1 ring-gray-700' : ''}`}
+ >
+
+
+ {topBadge.label}
+
+
+
+
+
+ {plan.category?.title || "WEB"}
+
+ {metrics.saving > 0 && metrics.isAvailable && (
+
+ Économie {metrics.saving}%
+
+ )}
+
+
+
+
+ {plan.title}
+ {isCurrentActive && Actif }
+
+
+ {metrics.isAvailable ? (
+ <>
+
+ {metrics.displayPrice} €
+ / mois
+
+
+ {metrics.saving > 0 && (
+
+ Au lieu de {metrics.oldDisplayPrice} € / mois
+
+ )}
+
+
+ Facturé {metrics.billingPrice} € par {billingPeriod === '1W' ? 'semaine' : billingPeriod === '1M' ? 'mois' : 'an'}
+
+ >
+ ) : (
+
+
Non disponible
+
Pour ce cycle
+
+ )}
+
+
+
+ {renderMarkdownFeatures(plan.description)}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {selectedPlan && !isNoChange && (
+
+
+
+ {isRefund
+ ? "Remboursement estimé"
+ : finalInvoicePrice > 0.01
+ ? "Montant à régler aujourd'hui"
+ : "Aucun frais immédiat"}
+
+ {isProrataApplied &&
Calculé au prorata des jours restants
}
+
+
+
+ {isRefund ? "-" : ""}{Math.abs(finalInvoicePrice).toFixed(2)} €
+
+
+
+ )}
+
+ {selectedPlan && (
+
+ {loading ? : (!isNoChange && ActionIcon)}
+
+ {loading
+ ? 'Traitement en cours...'
+ : isNoChange
+ ? 'Abonnement actuel (Aucune modification)'
+ : migrationLabel
+ }
+
+ )}
+
+ {/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
+
setIsCancelModalOpen(true)}
+ disabled={loading || cancelLoading}
+ className="w-full flex items-center justify-center gap-2 text-red-400/80 hover:text-red-400 border border-red-950/30 bg-red-950/5 hover:bg-red-950/15 p-3 rounded-xl text-xs font-bold uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {cancelLoading ? : }
+ {cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'}
+
+
+
+ {/* ========================================================================= */}
+ {/* 🛡️ MODALE DE CONFIRMATION (Thème Sombre Intégré) */}
+ {/* ========================================================================= */}
+ {isCancelModalOpen && (
+
+
+
+
+
+ Confirmer la résiliation
+
+
+ {/* Condition d'affichage : Rétractation J+14 vs Résiliation standard */}
+ {isEligibleForRefund ? (
+
+
+ Droit de rétractation (≤ 14 jours)
+
+
+ Votre abonnement a été activé il y a moins de 14 jours.
+ Vous allez être intégralement remboursé .
+ Vos services HestiaCP et bases de données seront supprimés immédiatement.
+
+
+ ) : (
+
+
📅 Résiliation standard (> 14 jours)
+
+ La période de rétractation est dépassée. Votre abonnement restera actif jusqu'à sa date d'échéance officielle.
+ Aucun renouvellement ni prélèvement n'aura lieu.
+
+
+ )}
+
+
+ Êtes-vous absolument sûr de vouloir procéder ? Cette action est irréversible une fois validée par nos serveurs.
+
+
+
+ setIsCancelModalOpen(false)}
+ disabled={cancelLoading}
+ className="px-5 py-2.5 bg-[#090f1c] text-gray-300 border border-gray-800 rounded-xl hover:bg-gray-800 hover:text-white disabled:opacity-50 transition-colors text-sm font-bold tracking-wide"
+ >
+ Retour
+
+
+ {cancelLoading ? (
+ <>
+
+ Traitement...
+ >
+ ) : (
+ "Confirmer"
+ )}
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/store/ProductCard.jsx b/src/components/store/ProductCard.jsx
index d9bcd74..3aee561 100644
--- a/src/components/store/ProductCard.jsx
+++ b/src/components/store/ProductCard.jsx
@@ -66,16 +66,16 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
)}
-