From b30a16b8a4b1ad3a1d5b1c71748ce71d30de67e2 Mon Sep 17 00:00:00 2001 From: LathanDevers Date: Fri, 17 Jul 2026 12:48:06 +0200 Subject: [PATCH] add resiliation --- src/components/dashboard/NewServiceCard.jsx | 2 +- .../WebServiceSubscriptionManager.jsx | 145 +++++++++++++----- src/pages/app/Dashboard.jsx | 25 ++- src/services/billing_api.js | 4 +- 4 files changed, 128 insertions(+), 48 deletions(-) diff --git a/src/components/dashboard/NewServiceCard.jsx b/src/components/dashboard/NewServiceCard.jsx index c64dda4..d8f8c00 100644 --- a/src/components/dashboard/NewServiceCard.jsx +++ b/src/components/dashboard/NewServiceCard.jsx @@ -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 index 7707399..19f1092 100644 --- a/src/components/dashboard/WebServiceSubscriptionManager.jsx +++ b/src/components/dashboard/WebServiceSubscriptionManager.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react'; -import { getProductList, getServiceDetails } from '../../services/api'; +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 }) { @@ -14,6 +14,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres 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(() => { @@ -39,8 +42,8 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres // 2. Extraction robuste du domaine let extractedDomain = "Aucun domaine lié"; - if (serviceDetails?.domain || serviceDetails?.config?.domain) { - extractedDomain = serviceDetails.domain || serviceDetails.config.domain; + if (serviceDetails?.config?.hostname || serviceDetails?.config?.sld && serviceDetails?.config?.tld) { + extractedDomain = serviceDetails.config.hostname || serviceDetails.config.sld+serviceDetails.config.tld; } const renderMarkdownFeatures = (text) => { @@ -174,12 +177,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres isRefund = finalInvoicePrice < -0.01; - // --- 3. Style du bouton (Triggers : Upgrade / Downgrade / Cycle) --- - - // On récupère le forfait actuel depuis le catalogue pour comparer la "puissance" brute des forfaits const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); - - // Pour définir la hiérarchie absolue, on compare les prix de base sur 1 Mois const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0; const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0; @@ -187,12 +185,10 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres const isSamePeriod = currentBillingPeriod === billingPeriod; if (isSamePlan && !isSamePeriod) { - // Le pack est identique, seule la période change 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) { - // Le pack change, on compare la hiérarchie absolue 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)]"; @@ -204,12 +200,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres } } } - // ========================================== const handleMigration = async () => { setLoading(true); - - // 🎯 On détermine dynamiquement le type d'action pour la facture let actionType = 'upgrade'; const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0); @@ -231,7 +224,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres target_plan_id: selectedPlan.id, new_period: billingPeriod, new_price: finalInvoicePrice.toFixed(2), - action_type: actionType // 🎯 On envoie l'information au backend ! + action_type: actionType }) }); const data = await resp.json(); @@ -249,40 +242,54 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres } }; - const handleCancel = async () => { - if (!window.confirm("Êtes-vous sûr de vouloir résilier cet abonnement ? Le service sera désactivé au prochain cycle de facturation.")) { - return; - } + // ========================================== + // 🛡️ 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 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: 'cancel', - order_id: order.id - }) - }); - const data = await resp.json(); + const data = await cancelOrder(order.id); + // Succès normal if (data.status === 'success') { - if (onAlert) onAlert("Résilié", data.message || "L'abonnement a été annulé avec succès.", "success"); + if (onAlert) onAlert("Résilié", data.message, "success"); + setIsCancelModalOpen(false); if (onRefresh) onRefresh(); if (onClose) onClose(); } else { - if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "error"); + if (onAlert) onAlert("Erreur", data.error, "error"); } } catch (err) { - if (onAlert) onAlert("Erreur", "Problème réseau ou serveur.", "error"); + 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 ( -
- +
@@ -316,10 +323,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres

- + Voir les factures
@@ -429,7 +433,6 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
- {/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */} {selectedPlan && !isNoChange && (
)} + {/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
+ + {/* ========================================================================= */} + {/* 🛡️ 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. +

+ +
+ + +
+
+
+ )}
); } \ No newline at end of file diff --git a/src/pages/app/Dashboard.jsx b/src/pages/app/Dashboard.jsx index 4358746..85f9dd3 100644 --- a/src/pages/app/Dashboard.jsx +++ b/src/pages/app/Dashboard.jsx @@ -20,8 +20,10 @@ export default function Dashboard() { const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type }); - const fetchInventory = async () => { - setIsLoading(true); + const fetchInventory = async (retryCount = 0) => { + // On n'affiche le loader global que lors du tout premier essai + if (retryCount === 0) setIsLoading(true); + try { const data = await getOrdersList(); @@ -37,17 +39,28 @@ export default function Dashboard() { title.startsWith('domaine ') || title.startsWith('enregistrement '); - return !isGhostProduct; + return (order.status === 'active' || order.status === 'pending_setup') && !isGhostProduct; }); setOrders(filteredOrders); } else { setOrders([]); } + + setError(null); // On efface l'erreur s'il y en avait une + setIsLoading(false); // Le chargement est terminé + } catch (err) { - setError(err.message || "Impossible de récupérer la télémétrie des services."); - } finally { - setIsLoading(false); + // 🛡️ SYSTÈME D'AUTO-GUÉRISON : Si on détecte le redémarrage d'HestiaCP (Erreur HTML/JSON) + if (err.message && err.message.includes('Unexpected token') && retryCount < 3) { + console.warn(`Redémarrage d'infrastructure détecté. Nouvelle tentative... (Essai ${retryCount + 1}/3)`); + // On attend 2,5 secondes, puis la fonction s'appelle elle-même (récursivité) + setTimeout(() => fetchInventory(retryCount + 1), 2500); + } else { + // S'il y a une vraie erreur persistante, on l'affiche au client + setError(err.message || "Impossible de récupérer la télémétrie des services."); + setIsLoading(false); + } } }; diff --git a/src/services/billing_api.js b/src/services/billing_api.js index 2accc99..19a67fe 100644 --- a/src/services/billing_api.js +++ b/src/services/billing_api.js @@ -206,9 +206,7 @@ export const changeClientPassword = (current_password, new_password) => // Resiliation // Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur) export const cancelOrder = (orderId) => { - // On utilise votre moteur apiCall existant, il transmettra automatiquement - // le token "Bearer" dans le header Authorization ! - return apiCall(`${CUSTOM_API_URL}/cancel_subscription.php`, 'POST', { + return apiCall(`${CUSTOM_API_BASE_URL}/custom_api/cancel_subscription.php`, 'POST', { order_id: orderId }); };